diff --git a/.claude/agents/feature-review.md b/.claude/agents/feature-review.md index 7c53a699d..e81e7c947 100644 --- a/.claude/agents/feature-review.md +++ b/.claude/agents/feature-review.md @@ -109,12 +109,14 @@ Coverage metrics are mandatory for every language that has changed files in the Coverage thresholds follow the uniform tier rule (Authoritative Decision #2) defined in `.claude/rules/quality-tiers.md`: -- **New code files** (files added in this feature, not previously existing): line coverage >= 85%, branch coverage >= 75%. -- **Modified files** (files that existed before and were changed): line coverage >= 85%, branch coverage >= 75%, and no regression on changed lines relative to baseline. -- **Repo-wide per language**: line coverage >= 85%, branch coverage >= 75%. +- **New code files** (files added in this feature, not previously existing): line coverage >= 85%, branch coverage >= 75% for branch-capable languages. +- **Modified files** (files that existed before and were changed): line coverage >= 85%, branch coverage >= 75% for branch-capable languages, and no regression on changed lines relative to baseline. +- **Repo-wide per language**: line coverage >= 85%, branch coverage >= 75% for branch-capable languages. Tier-specific lower thresholds are not used. +The branch threshold applies only to branch-capable languages — TypeScript, Python, and C#. PowerShell is a coverage language and is fully subject to the line threshold and the no-regression requirement, but Pester measures command (instruction) coverage and line coverage only, so no branch percentage exists to evaluate and no branch threshold applies to it (see `.claude/rules/powershell.md`). Do not record FAIL for an absent PowerShell branch figure. + ### Verification Procedure For each language that has changed files in the feature branch: diff --git a/.claude/agents/parallel-orchestrator.md b/.claude/agents/parallel-orchestrator.md index 80e30deb5..cc63224ad 100644 --- a/.claude/agents/parallel-orchestrator.md +++ b/.claude/agents/parallel-orchestrator.md @@ -187,10 +187,17 @@ Read `cohorts[] { index, generation, item_keys[] }` and schedule from it exactly Two scheduling rules govern every launch: -1. **Cohort barrier.** Cohort `N+1` branches from `main` only after every cohort-`N` item is - `merged` or `worktree_removed`. `current_cohort` increments only on durable confirmation via - `git` and `gh` commands, never from in-memory notifications. A blocked item is neither `merged` - nor `worktree_removed`, so a blocked item holds the barrier. +1. **Cohort barrier (per-edge).** An item may start only when every conflicting neighbour + (`conflict_edges[]`) that sits in a strictly prior current-generation cohort has `merge_status` + of `merged` or `worktree_removed`. `ci_green` does not satisfy the barrier. Same-cohort and + later-cohort neighbours do not hold an item back, and an item with no conflicting prior-cohort + neighbour may start regardless of other cohorts' progress. Evaluate the predicate only against + durable state confirmed by `git` and `gh` commands, never from in-memory notifications. A + blocked item is neither `merged` nor `worktree_removed`, so it holds only its own conflicting + later-cohort neighbours and, transitively, the tail of its own conflict component; unrelated + lanes keep advancing. `current_cohort` is a PROGRESS INDICATOR — the lowest current-generation + cohort index still holding a non-terminal, non-withdrawn item, updated only on the same durable + confirmation — and gates nothing. 2. **`max_concurrency` slot filling.** `max_concurrency` caps the number of simultaneously in-flight items independently of cohort size. Fill slots in ascending item-key (`issue_num`) order, and refill each freed slot with the next unstarted item of the current cohort in the same ascending diff --git a/.claude/agents/parallel-planner.md b/.claude/agents/parallel-planner.md index b98c3b6e2..e1a691af7 100644 --- a/.claude/agents/parallel-planner.md +++ b/.claude/agents/parallel-planner.md @@ -1,7 +1,7 @@ --- name: parallel-planner model: opus -description: Planning half of the parallel orchestration surface. It performs item intake over issue numbers and potential-entry paths, drives per-item preparation (promotion, research, spec/user-story, atomic plan, preflight clearance) through concurrent preparation-mode Agent(orchestrator) delegations, computes and validates each item's blast radius, seeds the generation-0 cohort table, writes the parallel run manifest and the planner checkpoint, and emits the parallel-orchestrator kickoff prompt artifact. Performs no atomic execution, PR authoring, or CI monitoring. +description: Planning half of the parallel orchestration surface. It performs item intake over issue numbers and potential-entry paths, drives per-item preparation (promotion, research, spec/user-story, atomic plan, preflight clearance) through preparation-mode Agent(orchestrator) delegations launched in bounded waves of at most max_concurrency, computes and validates each item's blast radius, seeds the generation-0 cohort table, writes the parallel run manifest and the planner checkpoint, and emits the parallel-orchestrator kickoff prompt artifact. Performs no atomic execution, PR authoring, or CI monitoring. tools: - "Agent(orchestrator)" - Read @@ -90,7 +90,10 @@ On every invocation: ## Delegation Model You delegate exclusively through `Agent(orchestrator)`, one delegation per item, each carrying the -preparation-mode kickoff line defined in the `parallel-plan` skill. Each child `orchestrator` runs +preparation-mode kickoff line defined in the `parallel-plan` skill. Delegations are launched in +BOUNDED WAVES of at most `max_concurrency`, computed with +`bash .claude/lib/bash/compute-concurrency-batches.sh`, with wave *k+1* launched only after every +child of wave *k* has terminated. Never launch every item's preparation at once. Each child `orchestrator` runs promotion, research, feature documents, atomic planning, and preflight clearance under `route_id: preparation`, commits and pushes its own branch, then stops before any execution. You do not delegate directly to `atomic-planner`, `atomic-executor`, `task-researcher`, or `prd-feature`; diff --git a/.claude/agents/typescript-engineer.md b/.claude/agents/typescript-engineer.md index 8c1211437..db9054c45 100644 --- a/.claude/agents/typescript-engineer.md +++ b/.claude/agents/typescript-engineer.md @@ -1,5 +1,6 @@ --- name: typescript-engineer +model: sonnet description: Project-scoped worker that implements and verifies TypeScript changes within typed repository boundaries. tools: - Read diff --git a/.claude/hooks/enforce-discovery-artifact-gate.ps1 b/.claude/hooks/enforce-discovery-artifact-gate.ps1 index 524077292..7274ed04a 100644 --- a/.claude/hooks/enforce-discovery-artifact-gate.ps1 +++ b/.claude/hooks/enforce-discovery-artifact-gate.ps1 @@ -25,8 +25,10 @@ interprets the CLI's exit code and captured output. .NOTES - Compatible with PowerShell 7+. Read-only validation gate; the validator - subprocess is the only external process invoked. + Requires PowerShell 7.4+ (the shared validation module uses + `Test-Json -SchemaFile` Draft 2020-12 support). Read-only validation gate that + invokes NO external process: validation runs in-process through + `.claude/lib/discovery-validation/DiscoveryValidation.psm1` (issue #475). #> [CmdletBinding()] param() @@ -34,11 +36,23 @@ param() function Invoke-DiscoveryValidatorExe { <# .SYNOPSIS - Wrapper around the discovery-artifact validator CLI. Mockable seam. + Wrapper around the discovery-artifact validator. Mockable seam. .DESCRIPTION - Invokes `python -m scripts.dev_tools.validate_discovery_artifacts` with - the supplied arguments and captures both stdout and stderr. Tests mock - this function directly; production code must never mock `python`. + Delegates to the portable PowerShell implementation in + `.claude/lib/discovery-validation/DiscoveryValidation.psm1`, keeping this + function's name, its `-ValidatorArgs ` parameter, and its + `@{ ExitCode; Output }` return shape unchanged so existing mocks and + `Should -Invoke` assertions continue to bind. + + This no longer invokes a Python interpreter (issue #475). The `.claude/**` + payload ships to destinations with no guaranteed Python, Poetry, or + `scripts/dev_tools`, where the previous `python -m ...` call failed + obscurely or blocked every operation. + + Success is SILENT by contract: a passing validation returns `ExitCode = 0` + with an EMPTY `Output`. The caller denies on a non-zero exit code OR on + non-empty output, so any success chatter here would deny a passing + validation (defect D-2). #> [CmdletBinding()] [OutputType([hashtable])] @@ -47,8 +61,14 @@ function Invoke-DiscoveryValidatorExe { [string[]] $ValidatorArgs ) - $output = & python -m scripts.dev_tools.validate_discovery_artifacts @ValidatorArgs 2>&1 - return @{ ExitCode = $LASTEXITCODE; Output = ($output | Out-String).Trim() } + $modulePath = Join-Path -Path $PSScriptRoot ` + -ChildPath '../lib/discovery-validation/DiscoveryValidation.psm1' + if (-not (Test-Path -LiteralPath $modulePath -PathType Leaf)) { + return @{ ExitCode = 1; Output = "Discovery-validation module not found: $modulePath" } + } + + Import-Module -Name $modulePath -Force -ErrorAction Stop + return Invoke-DiscoveryArtifactValidation -ValidatorArgs $ValidatorArgs } function Get-DiscoveryArtifactType { diff --git a/.claude/hooks/enforce-epic-merge-gate.ps1 b/.claude/hooks/enforce-epic-merge-gate.ps1 index 5acf145d5..4b24aee3d 100644 --- a/.claude/hooks/enforce-epic-merge-gate.ps1 +++ b/.claude/hooks/enforce-epic-merge-gate.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Invoked by the Claude Code PreToolUse hook on the "Bash" matcher before any Bash command runs. Regex-matches gh pr merge with a --merge flag against - CLAUDE_TOOL_INPUT.command and, when matched, allows the merge only when one of two + CLAUDE_TOOL_INPUT.command and, when matched, allows the merge only when one of three checkpoint-only conditions holds: 1. Child-feature path: artifacts/orchestration/orchestrator-state.json exists, @@ -14,11 +14,17 @@ 2. Epic-integration path: artifacts/orchestration/epic-orchestrator-state.json exists, epic_merge_pr.ci_gate.conclusion == "success", and, when the command names an explicit PR number, that number matches epic_merge_pr.pr_number. + 3. Parallel path: artifacts/orchestration/parallel-orchestrator-state.json exists, + route_id == "parallel", and the command's explicit PR number matches an items[] + entry whose merge_status == "ci_green". A parallel run always names an explicit PR + number (each item merges from its own isolated worktree), so a bare command with no + PR number cannot satisfy this branch. Otherwise the command is denied with reason EPIC_MERGE_GATE_BLOCKED. A missing or - unreadable checkpoint in either branch fails closed (denies); standalone (non-epic) - orchestration never sets epic_mode or populates epic_merge_pr, so it is structurally - prevented from invoking gh pr merge --merge at all. + unreadable checkpoint in any branch fails closed (denies); standalone (non-epic, + non-parallel) orchestration never sets epic_mode, populates epic_merge_pr, or writes a + parallel checkpoint with route_id == "parallel", so it is structurally prevented from + invoking gh pr merge --merge at all. Design decision: this gate trusts the on-disk checkpoint rather than shelling out live to gh pr view for a real-time head-SHA check, matching the same non-adversarial, @@ -35,6 +41,7 @@ param() $script:ChildCheckpointPath = 'artifacts/orchestration/orchestrator-state.json' $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json' +$script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json' function Get-ChildOrchestratorCheckpointContent { <# @@ -72,6 +79,24 @@ function Get-EpicOrchestratorCheckpointContent { return (Get-Content -LiteralPath $script:EpicCheckpointPath -Raw) } +function Get-ParallelOrchestratorCheckpointContent { + <# + .SYNOPSIS + Read the raw JSON text of the parallel-orchestrator checkpoint. Tests mock + this function (read seam). + .OUTPUTS + System.String or $null + #> + [CmdletBinding()] + [OutputType([string])] + param() + + if (-not (Test-Path -LiteralPath $script:ParallelCheckpointPath -PathType Leaf)) { + return $null + } + return (Get-Content -LiteralPath $script:ParallelCheckpointPath -Raw) +} + function ConvertFrom-EpicMergeGateJson { <# .SYNOPSIS @@ -113,9 +138,20 @@ function Get-EpicMergeGateCommandPrNumber { [string] $CommandText ) + # Original form: the PR number appears immediately after "merge" + # (e.g. "gh pr merge 410 --merge"). Preserved verbatim so epic-path outcomes + # for the forms the epic path uses are unchanged. if ($CommandText -match '(?i)\bgh\s+pr\s+merge\s+(\d+)\b') { return [int]$Matches[1] } + # Broadened, additive form: the parallel command places the flag before the + # number (e.g. "gh pr merge --merge 410"). Once "gh pr merge" is confirmed, + # capture the first standalone run of digits that is not preceded by "-" or a + # word character, so a flag token such as "--merge" is not treated as a number + # and a bare "gh pr merge --merge" still yields $null. + if ($CommandText -match '(?i)\bgh\s+pr\s+merge\b' -and $CommandText -match '(? + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowNull()] + $Checkpoint, + + [AllowNull()] + [Nullable[int]] $CommandPrNumber + ) + + if ($null -eq $Checkpoint) { + return $false + } + $props = @($Checkpoint.PSObject.Properties.Name) + if ($props -notcontains 'route_id' -or ([string]$Checkpoint.route_id) -ne 'parallel') { + return $false + } + # A parallel merge always names an explicit PR number; without one the target + # item cannot be identified, so fail closed. + if ($null -eq $CommandPrNumber) { + return $false + } + if ($props -notcontains 'items' -or $null -eq $Checkpoint.items) { + return $false + } + + foreach ($item in @($Checkpoint.items)) { + if ($null -eq $item) { + continue + } + $itemProps = @($item.PSObject.Properties.Name) + if ($itemProps -notcontains 'pr_number') { + continue + } + $itemPrNumber = 0 + if (-not [int]::TryParse([string]$item.pr_number, [ref] $itemPrNumber)) { + continue + } + if ($itemPrNumber -ne $CommandPrNumber) { + continue + } + if ($itemProps -notcontains 'merge_status') { + return $false + } + return ([string]$item.merge_status) -eq 'ci_green' + } + + return $false +} + function Get-EpicMergeGateAllowDecision { [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] @@ -284,7 +383,12 @@ function Invoke-EpicMergeGateDecision { return Get-EpicMergeGateAllowDecision } - return Get-EpicMergeGateBlockDecision -Reason 'EPIC_MERGE_GATE_BLOCKED: gh pr merge --merge requires either a per-feature checkpoint with epic_mode == true and step9_status == "passed", or an epic checkpoint with epic_merge_pr.ci_gate.conclusion == "success" and a matching pr_number. Neither checkpoint satisfied this gate.' + $parallelCheckpoint = ConvertFrom-EpicMergeGateJson -Raw (Get-ParallelOrchestratorCheckpointContent) + if (Test-ParallelCheckpointAllowsMerge -Checkpoint $parallelCheckpoint -CommandPrNumber $commandPrNumber) { + return Get-EpicMergeGateAllowDecision + } + + return Get-EpicMergeGateBlockDecision -Reason 'EPIC_MERGE_GATE_BLOCKED: gh pr merge --merge requires either a per-feature checkpoint with epic_mode == true and step9_status == "passed", an epic checkpoint with epic_merge_pr.ci_gate.conclusion == "success" and a matching pr_number, or a parallel-orchestrator checkpoint with route_id == "parallel" whose target item (matched by pr_number) has merge_status == "ci_green". No checkpoint satisfied this gate.' } # Guard allows dot-sourcing in tests without executing the entrypoint. diff --git a/.claude/hooks/enforce-mermaid-validation.ps1 b/.claude/hooks/enforce-mermaid-validation.ps1 new file mode 100644 index 000000000..1aee6afc5 --- /dev/null +++ b/.claude/hooks/enforce-mermaid-validation.ps1 @@ -0,0 +1,390 @@ +<# +.SYNOPSIS + Pre-tool-use hook for Claude Code that blocks Mermaid diagrams with named structural defects. + +.DESCRIPTION + This script is invoked by the Claude Code PreToolUse hook before any Write or Edit + operation. It reads the tool input from the CLAUDE_TOOL_INPUT environment variable + (JSON with 'file_path' plus 'content' for Write, or 'old_string'/'new_string' for + Edit) and applies two independent gates: + + 1. Syntax gate. On a Write of a '.mmd'/'.mermaid' file, the whole file is one + diagram. On a Write of a Markdown file, every fenced ```mermaid block is a + diagram. Each diagram is validated by .claude/lib/mermaid/MermaidValidation.psm1 + and a defect of a checked class produces a deny naming the class and the line. + 2. Managed-diagram gate. A '.mmd'/'.mermaid' file whose ON-DISK frontmatter carries + 'id:' is connected to the Mermaid Chart sync workflow and must not be hand + edited. This is a property of the target file rather than of the payload, so it + applies to Edit as well as Write, and the opt-out marker never suppresses it. + + The gate's contract is "rejects the named defect classes", never "proves validity". + Blocking a valid diagram is worse than missing an invalid one, so the hook declines + to judge rather than rejecting whenever it cannot classify content confidently: + + - empty, absent, or unparseable CLAUDE_TOOL_INPUT: allow; + - missing 'file_path', or a path outside the '.mmd'/'.mermaid'/Markdown scope: allow; + - the validation module absent from disk: allow; + - an Edit payload (the syntax check needs the whole file, which an + old_string/new_string fragment cannot supply): allow; + - a Markdown file carrying no ```mermaid fence: allow; + - a ```mermaid fence nested inside another open fence, which is documentation + showing example Mermaid rather than a diagram: skip that block; + - a fence immediately preceded by '': skip that + block, and only that block. + + DELIBERATE DIVERGENCE FROM enforce-evidence-locations.ps1: that hook throws on + malformed CLAUDE_TOOL_INPUT JSON and its entry point exits 1. This hook allows + instead. The difference is intentional and must not be "fixed" into a hard failure: + a content gate that hard-fails on input it cannot parse converts an unparseable + payload into a blocked write, which is the false-positive failure mode this feature + exists to avoid. The evidence-location hook gates a path, which is always parseable + when present; this hook gates content, which is not. + + The extension scope check runs before any content scan, so a write outside the + Mermaid scope pays only the JSON parse. + +.NOTES + Compatible with PowerShell 7+. + This script must not modify any state; it is a read-only validation gate. + It emits compact hookSpecificOutput JSON on stdout and exits 0 in every case, + never a non-zero exit and never the {"decision":"block"} shape. + It invokes no Python and starts no subprocess. +#> +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest + +$script:MermaidModulePath = Join-Path -Path $PSScriptRoot -ChildPath '../lib/mermaid/MermaidValidation.psm1' +$script:MermaidSkillPointer = 'See .claude/skills/mermaid-diagram/SKILL.md.' +$script:MermaidSyncPointer = 'Change it through the Mermaid Chart sync workflow in VS Code (Mermaid Chart extension: Sync Diagram with Mermaid, then Review Mermaid Sync) and pull the synced result instead of hand-editing. See .claude/rules/mermaid.md.' + +function Import-MermaidValidationModule { + <# + .SYNOPSIS + Imports the validation module, returning $false when it is absent. + .DESCRIPTION + A consumer repository that receives this hook without the library must not be + bricked, so a missing module fails open rather than throwing. + #> + [CmdletBinding()] + [OutputType([bool])] + param() + + if (-not (Test-Path -LiteralPath $script:MermaidModulePath -PathType Leaf)) { return $false } + + try { + Import-Module -Name $script:MermaidModulePath -Force -ErrorAction Stop + } catch { + return $false + } + + return $true +} + +function Get-MermaidOnDiskContent { + <# + .SYNOPSIS + Reads the current on-disk content of a target file, or $null when unavailable. + .DESCRIPTION + The named wrapper seam for the managed-diagram gate. Pester mocks this function + rather than the filesystem, so no test needs a temporary file. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + + try { + return [string](Get-Content -LiteralPath $Path -Raw -ErrorAction Stop) + } catch { + return $null + } +} + +function Get-MermaidToolInputField { + <# + .SYNOPSIS + Reads one field from the parsed tool input, or $null when it is absent. + #> + [CmdletBinding()] + param( + [AllowNull()] + $InputObject, + + [Parameter(Mandatory)] + [string] $Name + ) + + if ($null -eq $InputObject) { return $null } + + $property = $InputObject.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + + return $property.Value +} + +function Test-MermaidDiagramFilePath { + <# + .SYNOPSIS + Returns $true when the path names a standalone Mermaid diagram file. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $FilePath + ) + + $normalized = $FilePath -replace '\\', '/' + return [bool]($normalized -imatch '\.(mmd|mermaid)$') +} + +function Test-MermaidMarkdownFilePath { + <# + .SYNOPSIS + Returns $true when the path names a Markdown file that may carry a fence. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $FilePath + ) + + $normalized = $FilePath -replace '\\', '/' + return [bool]($normalized -imatch '\.(md|markdown)$') +} + +function Get-MermaidAllowDecision { + <# + .SYNOPSIS + Builds the explicit-allow decision. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param() + + return [ordered]@{ + hookSpecificOutput = [ordered]@{ + hookEventName = 'PreToolUse' + permissionDecision = 'allow' + } + } +} + +function Get-MermaidDenyDecision { + <# + .SYNOPSIS + Builds a deny decision carrying the supplied token-prefixed reason. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] + [string] $Reason + ) + + return [ordered]@{ + hookSpecificOutput = [ordered]@{ + hookEventName = 'PreToolUse' + permissionDecision = 'deny' + permissionDecisionReason = $Reason + } + } +} + +function Get-MermaidValidationBlockedReason { + <# + .SYNOPSIS + Formats the syntax-deny reason from a structured validation result. + .DESCRIPTION + The reason names the defect class, the line number, and the corrective pointer, + because a deny a reader cannot act on is indistinguishable from a broken gate. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string] $FilePath, + + [Parameter(Mandatory)] + $Result, + + [AllowEmptyString()] + [string] $Location = '' + ) + + $finding = @($Result.Findings)[0] + $declared = if ([string]::IsNullOrWhiteSpace([string]$Result.DiagramType)) { 'no diagram type' } else { "'$($Result.DiagramType)'" } + $where = if ([string]::IsNullOrWhiteSpace($Location)) { '' } else { " ($Location)" } + + return "MERMAID_VALIDATION_BLOCKED: '$FilePath'$where declares $declared and has a Mermaid syntax defect: $($finding.Class) at line $($finding.Line): $($finding.Message). $script:MermaidSkillPointer" +} + +function Get-MermaidManagedDiagramBlockedReason { + <# + .SYNOPSIS + Formats the managed-diagram deny reason. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string] $FilePath + ) + + return "MERMAID_MANAGED_DIAGRAM_BLOCKED: '$FilePath' is a Mermaid Chart-managed diagram: its on-disk frontmatter carries an 'id:' marker, so a hand-edit would be overwritten by the next sync. $script:MermaidSyncPointer" +} + +function Get-MermaidMarkdownBlockDecision { + <# + .SYNOPSIS + Validates every eligible fenced block of a Markdown payload. + .DESCRIPTION + Returns a deny decision for the first block carrying a defect, or $null when + every block is either accepted or skipped. A nested block is documentation + showing example Mermaid; an opted-out block carries the documented marker. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] + [string] $FilePath, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Content + ) + + foreach ($block in @(Get-MermaidFenceBlock -Content $Content)) { + if ($block.IsNested -or $block.IsOptedOut) { continue } + + $result = Test-MermaidDiagram -Content $block.Content -LineOffset ($block.BodyStartLine - 1) + if ($result.Verdict -ne 'Invalid') { continue } + + $location = "the mermaid fence opening at line $($block.StartLine)" + return Get-MermaidDenyDecision -Reason (Get-MermaidValidationBlockedReason -FilePath $FilePath -Result $result -Location $location) + } + + return $null +} + +function Invoke-MermaidValidationDecision { + <# + .SYNOPSIS + Parses the Claude Code tool-input JSON and returns the allow-or-deny decision. + .DESCRIPTION + The pure decision function, separated from the thin entry point so Pester + exercises the logic directly. Returns $null when the call is none of this hook's + business (out of scope, unparseable, or a Markdown file with no fence), which the + entry point treats as a silent allow. + .PARAMETER ToolInputRaw + The raw JSON string from $env:CLAUDE_TOOL_INPUT. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [AllowEmptyString()] + [AllowNull()] + [string] $ToolInputRaw + ) + + if ([string]::IsNullOrWhiteSpace($ToolInputRaw)) { return $null } + + try { + $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop + } catch { + # Fail open. See the DELIBERATE DIVERGENCE note in this file's header before + # changing this to a throw. + return $null + } + + $filePath = [string](Get-MermaidToolInputField -InputObject $toolInput -Name 'file_path') + if ([string]::IsNullOrWhiteSpace($filePath)) { return $null } + + # Scope check first: a write outside the Mermaid scope pays only the JSON parse. + $isDiagramFile = Test-MermaidDiagramFilePath -FilePath $filePath + $isMarkdownFile = Test-MermaidMarkdownFilePath -FilePath $filePath + if (-not ($isDiagramFile -or $isMarkdownFile)) { return $null } + + if (-not (Import-MermaidValidationModule)) { return $null } + + # Managed-diagram gate: a property of the target file, so Edit is covered without + # reconstructing the post-edit content, and the opt-out marker cannot suppress it. + if ($isDiagramFile) { + $onDisk = Get-MermaidOnDiskContent -Path $filePath + if (-not [string]::IsNullOrWhiteSpace([string]$onDisk) -and (Test-MermaidManagedDiagram -Content ([string]$onDisk))) { + return Get-MermaidDenyDecision -Reason (Get-MermaidManagedDiagramBlockedReason -FilePath $filePath) + } + } + + $content = Get-MermaidToolInputField -InputObject $toolInput -Name 'content' + if ($null -eq $content) { + # Edit payload: old_string/new_string is a fragment, not the resulting file, so + # the syntax check cannot run. The next Write catches a regression. + return Get-MermaidAllowDecision + } + + if ($isDiagramFile) { + $result = Test-MermaidDiagram -Content ([string]$content) + if ($result.Verdict -eq 'Invalid') { + return Get-MermaidDenyDecision -Reason (Get-MermaidValidationBlockedReason -FilePath $filePath -Result $result) + } + + return Get-MermaidAllowDecision + } + + $blocks = @(Get-MermaidFenceBlock -Content ([string]$content)) + if ($blocks.Count -eq 0) { return $null } + + $decision = Get-MermaidMarkdownBlockDecision -FilePath $filePath -Content ([string]$content) + if ($null -ne $decision) { return $decision } + + return Get-MermaidAllowDecision +} + +function Invoke-MermaidValidationEntryPoint { + <# + .SYNOPSIS + Thin entry point: writes the decision JSON to stdout and nothing else. + .DESCRIPTION + The function emits only the JSON, and the caller exits 0 unconditionally. An + entry point that also returned a status code would place that code in the same + output stream as the JSON, so the caller would consume the JSON instead of + printing it and the decision would never reach Claude Code. + .PARAMETER ToolInputRaw + The raw JSON string from $env:CLAUDE_TOOL_INPUT. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [AllowEmptyString()] + [AllowNull()] + [string] $ToolInputRaw = $env:CLAUDE_TOOL_INPUT + ) + + $decision = Invoke-MermaidValidationDecision -ToolInputRaw $ToolInputRaw + if ($null -eq $decision) { return } + + $decision | ConvertTo-Json -Compress -Depth 5 +} + +# Guard allows dot-sourcing in tests without executing the entrypoint. +if ($MyInvocation.InvocationName -eq '.') { + return +} + +Invoke-MermaidValidationEntryPoint -ToolInputRaw $env:CLAUDE_TOOL_INPUT + +# Exit 0 on allow and on deny alike: the decision travels in the JSON, never in the +# exit code. +exit 0 diff --git a/.claude/hooks/validate-discovery-artifact-gate.ps1 b/.claude/hooks/validate-discovery-artifact-gate.ps1 index 06d6c4406..0fb4faaa6 100644 --- a/.claude/hooks/validate-discovery-artifact-gate.ps1 +++ b/.claude/hooks/validate-discovery-artifact-gate.ps1 @@ -28,8 +28,10 @@ captured output. .NOTES - Compatible with PowerShell 7+. Read-only validation gate; the validator - subprocess is the only external process invoked. + Requires PowerShell 7.4+ (the shared validation module uses + `Test-Json -SchemaFile` Draft 2020-12 support). Read-only validation gate that + invokes NO external process: validation runs in-process through + `.claude/lib/discovery-validation/DiscoveryValidation.psm1` (issue #475). #> [CmdletBinding()] param() @@ -37,11 +39,23 @@ param() function Invoke-DiscoveryValidatorExe { <# .SYNOPSIS - Wrapper around the discovery-artifact validator CLI. Mockable seam. + Wrapper around the discovery-artifact validator. Mockable seam. .DESCRIPTION - Invokes `python -m scripts.dev_tools.validate_discovery_artifacts` with - the supplied arguments and captures both stdout and stderr. Tests mock - this function directly; production code must never mock `python`. + Delegates to the portable PowerShell implementation in + `.claude/lib/discovery-validation/DiscoveryValidation.psm1`, keeping this + function's name, its `-ValidatorArgs ` parameter, and its + `@{ ExitCode; Output }` return shape unchanged so existing mocks and + `Should -Invoke` assertions continue to bind. + + This no longer invokes a Python interpreter (issue #475). The `.claude/**` + payload ships to destinations with no guaranteed Python, Poetry, or + `scripts/dev_tools`, where the previous `python -m ...` call failed + obscurely or blocked every operation. + + Success is SILENT by contract: a passing validation returns `ExitCode = 0` + with an EMPTY `Output`. The caller denies on a non-zero exit code OR on + non-empty output, so any success chatter here would deny a passing + validation (defect D-2). #> [CmdletBinding()] [OutputType([hashtable])] @@ -50,8 +64,14 @@ function Invoke-DiscoveryValidatorExe { [string[]] $ValidatorArgs ) - $output = & python -m scripts.dev_tools.validate_discovery_artifacts @ValidatorArgs 2>&1 - return @{ ExitCode = $LASTEXITCODE; Output = ($output | Out-String).Trim() } + $modulePath = Join-Path -Path $PSScriptRoot ` + -ChildPath '../lib/discovery-validation/DiscoveryValidation.psm1' + if (-not (Test-Path -LiteralPath $modulePath -PathType Leaf)) { + return @{ ExitCode = 1; Output = "Discovery-validation module not found: $modulePath" } + } + + Import-Module -Name $modulePath -Force -ErrorAction Stop + return Invoke-DiscoveryArtifactValidation -ValidatorArgs $ValidatorArgs } function Get-DiscoveryArtifactType { diff --git a/.claude/hooks/validate-orchestrator-output.ps1 b/.claude/hooks/validate-orchestrator-output.ps1 index e3e317d2c..9080374e4 100644 --- a/.claude/hooks/validate-orchestrator-output.ps1 +++ b/.claude/hooks/validate-orchestrator-output.ps1 @@ -149,31 +149,94 @@ function Test-HumanInteractionShape { return @{ Ok = $true; Message = $null } } +function Test-OrchestratorCheckpointStructure { + <# + .SYNOPSIS + Type-scoped structural check for the epic and parallel checkpoint types. + .DESCRIPTION + PD-3 implementation. The Python reference exposes no validation surface for + `epic-orchestrator-state` or `parallel-orchestrator-state` under this hook's + flag pair: argparse rejects the pair and exits 2 without running a single + check, so parity is UNDEFINED in this region. Rather than inherit an + undefined behavior, this hook defines it: the checkpoint must exist, parse + as JSON, and have an object root. That is the largest assertion that holds + for every checkpoint type without importing a schema this hook does not own. + + Deliberately NOT applied here: + - the standard-checkpoint REQUIRED_STATE_KEYS presence block, whose key + set belongs to the standard checkpoint and would produce false + ROUTING_CONTRACT_BLOCKED verdicts against a well-formed epic or + parallel checkpoint (defect D-1), + - the model-routing gate, whose receipts live on the standard checkpoint. + + The check fails closed: a missing file, unreadable content, invalid JSON, or + a non-object root all yield ExitCode 1 with the load error as Output. + .PARAMETER CheckpointPath + The path to the checkpoint JSON file. + .OUTPUTS + System.Collections.Hashtable with keys ExitCode (int, 0 or 1) and Output (string). + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] $CheckpointPath + ) + + # Get-OrchestratorStateCheckpoint already implements exactly the three + # structural conditions (exists, parses, object root) and reports each as a + # fail-closed Error string, so the structural leg reuses it rather than + # duplicating the load contract. + $loaded = Get-OrchestratorStateCheckpoint -CheckpointPath $CheckpointPath + if (-not $loaded.Ok) { + return @{ ExitCode = 1; Output = $loaded.Error } + } + + return @{ ExitCode = 0; Output = '' } +} + function Invoke-RoutingContractValidation { <# .SYNOPSIS - Runs the authoritative Python routing-contract validator against the - on-disk checkpoint and reports whether it emitted errors. + Runs the portable routing-contract validation against the on-disk + checkpoint and reports whether it emitted errors. .DESCRIPTION - Invokes the validator through an injectable subprocess scriptblock seam. - The default Invoker runs the authoritative Python CLI: - python -m scripts.dev_tools.validate_orchestration_artifacts \ - --require-complete - Tests inject a mock scriptblock so no Python process runs. The function - does not reimplement routing logic; it delegates to the Python validator. - ArtifactType defaults to 'orchestrator-state' so the default invocation - string is unchanged for every existing caller of this hook. + Invokes the validation through an injectable scriptblock seam. As of issue + #475 the default Invoker names no interpreter and starts no subprocess: the + portable PowerShell path is the ONLY path, so the hook behaves identically + in this repository and in every destination that received only the + pushed-down `.claude` pack. The former capability-detection probe and the + interpreter-subprocess leg it guarded are both gone. + + The default Invoker dispatches on ArtifactType: + + orchestrator-state + the COMPLETE-PARITY completion validation + (Test-OrchestratorStateCompletionReadiness), a row-by-row port of the + Python call surface `--require-complete --require-model-routing`. + + epic-orchestrator-state, parallel-orchestrator-state + the type-scoped structural check + (Test-OrchestratorCheckpointStructure): exists, parses as JSON, + object root. PD-3: this is DEFINED behavior in a region where Python + parity is UNDEFINED (argparse exit 2, zero checks run). It is a + design decision, not a deferral. The standard-checkpoint + REQUIRED_STATE_KEYS block and the model-routing gate are deliberately + not applied, which is the D-1 fix. + + anything else + fail closed, naming the unsupported type. An unrecognized type must + never read as a clean pass. + + ArtifactType defaults to 'orchestrator-state' so every existing caller of + this hook keeps its current behavior. Returns a hashtable with keys: - - HasErrors: $true only when the validator reported a non-zero exit + - HasErrors: $true only when the validation reported a non-zero exit code; $false when it exited 0. The exit code is the sole - discriminator, because the validator prints its success - line to stdout on a clean pass and the default Invoker - captures with 2>&1, so output text is present on success. - - ErrorText: the validator's combined captured output text, carried - through unchanged: the error lines on a failure, and the - success line (Python CLI) or empty (portable fallback) - on a clean pass. + discriminator. + - ErrorText: the validation's output text, carried through unchanged: + the error lines on a failure, empty on a clean pass. #> [CmdletBinding()] [OutputType([hashtable])] @@ -187,29 +250,36 @@ function Invoke-RoutingContractValidation { [Parameter(Mandatory = $false)] [scriptblock] $Invoker = { param($Path, $Type) - # Capability detection: use the authoritative Python CLI when - # scripts.dev_tools is importable (drm-copilot); otherwise fall back to - # the portable PowerShell completion module that travels with the - # pushed-down pack. The portable path performs the presence-level - # required-once-delegated existence gate and still fails closed. - if (Test-PythonOrchestratorValidatorAvailable) { - $output = & python -m scripts.dev_tools.validate_orchestration_artifacts ` - $Type $Path --require-complete --require-model-routing 2>&1 - [pscustomobject]@{ - ExitCode = $LASTEXITCODE - Output = ($output | Out-String) + switch ($Type) { + 'orchestrator-state' { + # Import the portable completion module only when its function is not + # already available, so a repeated call (or a test that pre-imports and + # mocks the function) does not reload the module and reset the seam. + if (-not (Get-Command -Name Test-OrchestratorStateCompletionReadiness -ErrorAction SilentlyContinue)) { + Import-Module (Join-Path $PSScriptRoot '../lib/orchestrator-state/OrchestratorStateCompletion.psm1') -Force + } + $portable = Test-OrchestratorStateCompletionReadiness -CheckpointPath $Path + [pscustomobject]@{ + ExitCode = $portable.ExitCode + Output = $portable.Output + } } - } else { - # Import the portable completion module only when its function is not - # already available, so a repeated call (or a test that pre-imports and - # mocks the function) does not reload the module and reset the seam. - if (-not (Get-Command -Name Test-OrchestratorStateCompletionReadiness -ErrorAction SilentlyContinue)) { - Import-Module (Join-Path $PSScriptRoot '../lib/orchestrator-state/OrchestratorStateCompletion.psm1') -Force + { $_ -in @('epic-orchestrator-state', 'parallel-orchestrator-state') } { + # PD-3: defined fail-closed structural behavior in an + # undefined-parity region. See Test-OrchestratorCheckpointStructure. + $structural = Test-OrchestratorCheckpointStructure -CheckpointPath $Path + [pscustomobject]@{ + ExitCode = $structural.ExitCode + Output = $structural.Output + } } - $portable = Test-OrchestratorStateCompletionReadiness -CheckpointPath $Path - [pscustomobject]@{ - ExitCode = $portable.ExitCode - Output = $portable.Output + default { + # Fail closed on an unwired type: an unrecognized artifact type is + # not a clean pass. + [pscustomobject]@{ + ExitCode = 1 + Output = "orchestrator hook: unsupported artifact type '$Type'; no validation surface is wired for it. Supported types: orchestrator-state, epic-orchestrator-state, parallel-orchestrator-state." + } } } } @@ -225,10 +295,10 @@ function Invoke-RoutingContractValidation { $outputText = ([string]$result.Output).Trim() } - # The exit code is the complete failure discriminator: the validator prints every - # error to stderr and returns non-zero, and prints its success line to stdout and - # returns 0. Because the default invoker captures with 2>&1, the success line lands - # in $outputText on a clean pass, so output text must not influence this decision. + # The exit code is the complete failure discriminator: every dispatch leg + # returns a non-zero ExitCode with its error text on failure and ExitCode 0 + # with empty Output on a clean pass, so output text must not influence this + # decision. $hasErrors = ($exitCode -ne 0) return @{ HasErrors = $hasErrors; ErrorText = $outputText } } @@ -313,9 +383,10 @@ function Invoke-OrchestratorOutputValidation { return @{ Ok = $false; Message = $hiResult.Message } } - # Delegate to the authoritative Python routing-contract validator. The - # optional RoutingInvoker seam lets tests inject a mock; the default seam - # produces the real subprocess call. + # Delegate to the portable routing-contract validation, dispatched on + # ArtifactType. The optional RoutingInvoker seam lets tests inject a mock; the + # default seam runs the in-process PowerShell validation and starts no + # subprocess. $routingArgs = @{ CheckpointPath = $CheckpointPath; ArtifactType = $ArtifactType } if ($PSBoundParameters.ContainsKey('RoutingInvoker') -and $null -ne $RoutingInvoker) { $routingArgs['Invoker'] = $RoutingInvoker diff --git a/.claude/lib/bash/parallel-manifest-validate.sh b/.claude/lib/bash/parallel-manifest-validate.sh index 8b5a4ceb9..08daaee0d 100644 --- a/.claude/lib/bash/parallel-manifest-validate.sh +++ b/.claude/lib/bash/parallel-manifest-validate.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # parallel-manifest-validate.sh: sourceable bash port of # scripts/dev_tools/parallel_manifest_contract.py. Validates a parallel-run -# manifest document against invariants M1 through M7 and exposes the two +# manifest document against invariants M1 through M8 and exposes the two # default-resolving accessors that every consumer uses instead of reading # `mode` and `max_concurrency` directly. # @@ -9,7 +9,8 @@ # frontmatter checks short-circuit with a single error; then run identity # (M2 parallel, M3 mode, M4 max_concurrency, M5 created_at) in schema field # order; then the M7 prohibited-key scan, deep results in document order -# followed by the top-level results; then the M6 items collection. +# followed by the top-level results; then the M6 items collection; then the +# key-gated M8 expected_conflict_components assertion. # # Every error string begins with the literal prefix `Parallel manifest` and # ends with a period. The Python module remains the repository authority. @@ -43,7 +44,7 @@ PM_DEFAULT_MAX_CONCURRENCY=4 # Inclusive bounds on a present `max_concurrency` (invariant M4, A7). PM_MIN_CONCURRENCY=1 -PM_MAX_CONCURRENCY=8 +PM_MAX_CONCURRENCY=32 # Keys the manifest rejects at any nesting level (invariant M7). PM_DEEP_PROHIBITED_KEYS="depends_on" @@ -54,6 +55,11 @@ PM_TOP_LEVEL_PROHIBITED_KEYS="integration_branch" # Detail recorded when the parser refuses an out-of-subset construct. PM_SUBSET_DETAIL="" +# Issue numbers already claimed by an earlier expected_conflict_components +# entry (invariant M8). Threaded across components so cross-component duplicate +# membership is decided in one pass; reset by pm_validate_text. +PM_CLAIMED="" + pm_parse_manifest() { # Parse manifest text into the shared node table (invariant M1). # @@ -128,6 +134,108 @@ pm_validate_identity() { fi } +pm_declared_issue_nums() { + # Echo the space-separated issue_num values the items collection declares. + # + # Supplies the resolution target for invariant M8 without re-running the M6 + # item validation: a malformed entry is reported once by M6 and simply + # contributes no resolvable key here. + local declared="" count index entry_path issue_type issue_value + [[ $(yp_type_of "items") == seq ]] || return 0 + count=$(yp_count_of "items") + # Accept only entries whose primary key is already well formed; admitting a + # malformed one would produce a second, confusing M8 error for one defect. + for ((index = 0; index < count; index++)); do + entry_path="items[${index}]" + [[ $(yp_type_of "$entry_path") == map ]] || continue + issue_type=$(yp_type_of "${entry_path}.issue_num") + issue_value=$(yp_value_of "${entry_path}.issue_num") + pc_is_positive_integer "$issue_type" "$issue_value" || continue + declared="$declared $issue_value" + done + printf '%s' "$declared" +} + +pm_validate_component_members() { + # Validate one component's members list against invariant M8. + # + # Args: $1 = node path of the members list, $2 = component-scoped context + # prefix, $3 = space-separated declared issue_num values. Appends every + # accepted key to PM_CLAIMED so the caller's running set enforces the + # no-duplicate-membership rule across components. + local path="$1" ctx="$2" declared="$3" + local members_type count=0 index slot entry_path member_type member_value + members_type=$(yp_type_of "$path") + if [[ $members_type == seq ]]; then + count=$(yp_count_of "$path") + fi + # A missing key, a scalar, and an empty list are one violation: the + # component asserts no membership and therefore carries no information. + if [[ $members_type != seq ]] || ((count == 0)); then + pc_error_add "$ctx members must be a non-empty list of positive integers." + return 0 + fi + + # Three successive gates per member; the first failure ends that member's + # checks, because an unusable value cannot be resolved and an unresolved key + # cannot meaningfully duplicate another. + for ((index = 0; index < count; index++)); do + entry_path="${path}[${index}]" + slot="$ctx members[${index}]" + member_type=$(yp_type_of "$entry_path") + member_value=$(yp_value_of "$entry_path") + if ! pc_is_positive_integer "$member_type" "$member_value"; then + pc_error_add "$slot must be a positive integer; found: $(pi_repr_at "$entry_path")." + continue + fi + if ! pc_contains_word "$declared" "$member_value"; then + pc_error_add "$slot does not resolve to an items[] issue_num; found: $member_value." + continue + fi + if pc_contains_word "$PM_CLAIMED" "$member_value"; then + pc_error_add "$slot repeats issue_num $member_value, already claimed by an earlier component." + continue + fi + PM_CLAIMED="$PM_CLAIMED $member_value" + done +} + +pm_validate_expected_components() { + # Validate the optional expected_conflict_components key (invariant M8). + # + # Key gated: absence is the overwhelmingly common shape and must cost the + # caller nothing, so the whole invariant is skipped rather than defaulted + # and a pre-M8 manifest's error list is unchanged. + local root="expected_conflict_components" + yp_has "$root" || return 0 + if [[ $(yp_type_of "$root") != seq ]]; then + pc_error_add "$PM_CONTEXT $root must be a list." + return 0 + fi + + local declared total position comp_path ctx name_type name_value + declared=$(pm_declared_issue_nums) + total=$(yp_count_of "$root") + for ((position = 0; position < total; position++)); do + comp_path="${root}[${position}]" + ctx="$PM_CONTEXT ${root}[${position}]" + if [[ $(yp_type_of "$comp_path") != map ]]; then + pc_error_add "$ctx must be an object." + continue + fi + # Field order follows the documented authoring order: the optional + # diagnostic label first, then the required membership list. + if yp_has "${comp_path}.name"; then + name_type=$(yp_type_of "${comp_path}.name") + name_value=$(yp_value_of "${comp_path}.name") + if ! pc_is_non_empty_string "$name_type" "$name_value"; then + pc_error_add "$ctx name must be a non-empty string." + fi + fi + pm_validate_component_members "${comp_path}.members" "$ctx" "$declared" + done +} + pm_validate_text() { # Validate a manifest document against invariants M1 to M7. # @@ -139,6 +247,7 @@ pm_validate_text() { local text="$1" parse_status=0 pc_errors_reset PM_SUBSET_DETAIL="" + PM_CLAIMED="" pm_parse_manifest "$text" || parse_status=$? # An out-of-subset refusal propagates so the caller can distinguish it from # a validation verdict; an M1 failure returns the single-element error list @@ -156,6 +265,9 @@ pm_validate_text() { # The manifest carries `kind` on every item, unlike the orchestrator # checkpoint, so the shared item validator is asked to require it (S1). pi_validate_items "items" "$PM_CONTEXT" 1 + # M8 runs last because its membership check resolves against the same items + # collection the previous call validated. + pm_validate_expected_components return 0 } diff --git a/.claude/lib/blast-radius/BlastRadius.psm1 b/.claude/lib/blast-radius/BlastRadius.psm1 index 8380f3191..25b9f968a 100644 --- a/.claude/lib/blast-radius/BlastRadius.psm1 +++ b/.claude/lib/blast-radius/BlastRadius.psm1 @@ -54,6 +54,7 @@ Set-StrictMode -Version Latest 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 # Feature-folder handling. Every radius contains its own feature folder, and a @@ -66,6 +67,11 @@ $script:FeatureFolderPrefix = 'docs/features/' $script:SourceDerived = 'derived' $script:SourceObserved = 'observed' +# A contract identifier names something callable or referenceable, so it must +# carry at least one ASCII letter (issue #489). Get-NormalizedDeclaredRadius +# re-applies that rule to identifiers a pre-#489 extractor already recorded. +$script:ContractLetterPattern = [regex]::new('[A-Za-z]') + # Contention reason kinds, in the fixed order every result reports them. These # strings are contract literals consumed by the downstream parallel schema. $script:ConflictPathOverlap = 'path_overlap' @@ -167,10 +173,20 @@ function Get-BlastRadius { $entry = [System.Collections.Generic.List[string]]::new() $entry.AddRange([string[]]@(Get-PlanPaths -PlanText $PlanText -RootSurface $rootSurface)) $entry.AddRange([string[]]@(Get-PathFromLine -Line $specLine -RootSurface $rootSurface)) - $entry.Add((Get-FeatureFolderGlob -FeatureFolder ( + + # Read-by-mandate citations are dropped before the feature folder is added: a + # plan cites the policy rules because its author was told to read them, not + # because the change will write them (issue #489). Test-BlastRadius applies + # the same filter, which is what keeps the derived radius passing V1 and V2 + # against its own plan. The feature-folder glob is added afterwards so it can + # never be excluded. + $surviving = [System.Collections.Generic.List[string]]::new() + $surviving.AddRange([string[]]@(Get-NonMandateReadEntry -Entry $entry.ToArray() ` + -MandateRead ([string[]]@(Get-ConfigMandateRead -Config $Config)))) + $surviving.Add((Get-FeatureFolderGlob -FeatureFolder ( Get-RequiredText -Value $FeatureFolder -FieldName 'feature_folder'))) - $paths = [string[]]@(Get-OrdinalSortedEntry -Entry $entry.ToArray()) + $paths = [string[]]@(Get-OrdinalSortedEntry -Entry $surviving.ToArray()) $concrete = [string[]]@(Get-ConcreteEntry -Entry $paths) return ConvertTo-NormalizedBlastRadius -Radius @{ @@ -183,6 +199,92 @@ function Get-BlastRadius { } } +function Get-NormalizedDeclaredRadius { + <# + .SYNOPSIS + Re-apply the current extraction rules to an already-recorded radius. + + .DESCRIPTION + Port of normalize_declared_radius. A radius recorded by an older + extractor can carry entries the current rules reject: directory-shaped + tokens, cross-corpus documentation globs, read-by-mandate citations, and + letterless contract tokens. Re-deriving from the plan text is not always + possible, so this function re-filters the recorded radius in place of a + fresh derivation and re-resolves the levels that depend on the surviving + paths (issue #489). source and computed_at are preserved and the input is + never mutated. + + .PARAMETER Radius + A derived or declared radius hashtable to re-filter. + + .PARAMETER Config + Parsed config/blast-radius.json. The root-surface set, the mandate-read + list, the module map, and the shared-surface list are all read from this + one value. + + .OUTPUTS + System.Collections.Hashtable. A new radius carrying the surviving paths, + contracts re-filtered by the ASCII-letter rule, and modules and shared + surfaces re-resolved from the surviving paths. Throws when the radius + source is observed: an observed radius records a diff listing rather than + a plan-text harvest, so the plan-text acceptance rules do not apply to it + and re-filtering one would silently discard genuine evidence. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Radius, + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Config + ) + + $normalized = ConvertTo-NormalizedBlastRadius -Radius $Radius + + # Fail fast, before any other work, so the prohibition is unambiguous and no + # partially-filtered value can escape. + if ($normalized['source'] -eq $script:SourceObserved) { + throw ("Get-NormalizedDeclaredRadius rejects a radius whose source is " + + "'$($script:SourceObserved)': an observed radius records a diff listing, " + + 'not a plan-text harvest, so the plan-text acceptance rules must not ' + + 'be applied to it.') + } + + $rootSurface = [string[]]@(Get-ConfigRootSurface -Config $Config) + + # Re-run the classifier over each recorded entry. An entry the current rules + # reject is dropped; the mandate-read filter then removes the citations that + # are evidence of a read rather than of a write. + $accepted = [System.Collections.Generic.List[string]]::new() + foreach ($entry in @($normalized['paths'])) { + if ($null -ne (Get-PathTokenKind -Token $entry -RootSurface $rootSurface)) { + $accepted.Add([string]$entry) + } + } + + $paths = [string[]]@(Get-NonMandateReadEntry -Entry $accepted.ToArray() ` + -MandateRead ([string[]]@(Get-ConfigMandateRead -Config $Config))) + $concrete = [string[]]@(Get-ConcreteEntry -Entry $paths) + + $contract = [System.Collections.Generic.List[string]]::new() + foreach ($identifier in @($normalized['contracts'])) { + if ($script:ContractLetterPattern.IsMatch([string]$identifier)) { + $contract.Add([string]$identifier) + } + } + + return ConvertTo-NormalizedBlastRadius -Radius @{ + paths = $paths + modules = @(Resolve-BlastRadiusModule -PathEntry $paths -Config $Config) + shared_surfaces = @(Resolve-BlastRadiusSharedSurface -ConcretePath $concrete -Config $Config) + contracts = @($contract.ToArray()) + source = $normalized['source'] + computed_at = $normalized['computed_at'] + } +} + function Get-BlastRadiusFromObservedPaths { <# .SYNOPSIS @@ -374,6 +476,7 @@ function Test-BlastRadiusConflict { Export-ModuleMember -Function ` Get-PlanPaths, ` Get-BlastRadius, ` + Get-NormalizedDeclaredRadius, ` Get-BlastRadiusFromObservedPaths, ` Test-BlastRadius, ` Test-BlastRadiusConflict diff --git a/.claude/lib/blast-radius/BlastRadiusConfig.psm1 b/.claude/lib/blast-radius/BlastRadiusConfig.psm1 index dcef71594..5cb6cd1fa 100644 --- a/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +++ b/.claude/lib/blast-radius/BlastRadiusConfig.psm1 @@ -39,6 +39,7 @@ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') $script:ConfigSharedSurfaceKey = 'shared_surfaces' $script:ConfigSharedSurfaceGlobKey = 'shared_surface_globs' $script:ConfigModuleKey = 'modules' +$script:ConfigMandateReadKey = 'mandate_reads' $script:ConfigOverBreadthKey = 'over_breadth_fraction' # Numeric types a JSON or literal truth table may carry for the V3 threshold. @@ -279,6 +280,36 @@ function Get-ConfigRootSurface { return @($rootSurface.ToArray()) } +function Get-ConfigMandateRead { + <# + .SYNOPSIS + Read the read-by-mandate exclusion list from the truth table. + + .DESCRIPTION + Port of config_mandate_reads. Mandate reads are the paths every agent is + instructed to read before doing any work, so a citation of one of them is + evidence that the author obeyed the reading order rather than evidence + that the change will write the file (issue #489). + + .PARAMETER Config + Parsed config/blast-radius.json. Only the mandate_reads key is read. + + .OUTPUTS + System.Object[]. Entries sorted and deduplicated by the underlying + reader. A config with no mandate_reads key yields an empty array, which + excludes nothing and reproduces pre-change behavior. + #> + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Config + ) + + return @(Get-ConfigStringList -Config $Config -Key $script:ConfigMandateReadKey) +} + function Get-ConfigModuleEntry { <# .SYNOPSIS @@ -374,57 +405,6 @@ function Get-ConfigOverBreadthFraction { return $fraction } -function Resolve-BlastRadiusModule { - <# - .SYNOPSIS - Resolve path entries to the module names of the truth-table map. - - .DESCRIPTION - Port of resolve_modules. A module joins the radius as soon as one of its - globs covers one entry, so the search stops at the first hit per module. - A path matching no glob resolves to no module. - - .PARAMETER PathEntry - Concrete paths and globs of a radius. An empty collection is accepted. - - .PARAMETER Config - Parsed config/blast-radius.json. - - .OUTPUTS - System.Object[]. Matched module names, deduplicated and ordinally sorted. - #> - [CmdletBinding()] - [OutputType([System.Object[]])] - param( - [Parameter(Mandatory = $true)] - [AllowEmptyCollection()] - [AllowEmptyString()] - [string[]] $PathEntry, - [Parameter(Mandatory = $true)] - [AllowNull()] - [object] $Config - ) - - $matched = [System.Collections.Generic.List[string]]::new() - foreach ($pair in @(Get-ConfigModuleEntry -Config $Config)) { - foreach ($pattern in $pair['globs']) { - $hit = $false - foreach ($entry in $PathEntry) { - if (Test-GlobMatch -Pattern $pattern -Candidate $entry) { - $matched.Add([string]$pair['name']) - $hit = $true - break - } - } - if ($hit) { - break - } - } - } - - return @(Get-OrdinalSortedEntry -Entry $matched.ToArray()) -} - function Resolve-BlastRadiusSharedSurface { <# .SYNOPSIS @@ -485,7 +465,7 @@ Export-ModuleMember -Function ` Get-RequiredMapping, ` Get-ConfigStringList, ` Get-ConfigRootSurface, ` + Get-ConfigMandateRead, ` Get-ConfigModuleEntry, ` Get-ConfigOverBreadthFraction, ` - Resolve-BlastRadiusModule, ` Resolve-BlastRadiusSharedSurface diff --git a/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 b/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 index dfdb1bb46..608f78f69 100644 --- a/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +++ b/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 @@ -6,8 +6,11 @@ Destination-runtime PowerShell port of the text-scanning half of scripts/dev_tools/_blast_radius_extraction.py. Normalizes line endings, partitions atomic-plan lines, extracts backtick-delimited inline-code tokens, - classifies those tokens as concrete repository paths or globs, and extracts - contract identifiers from a feature spec's interface sections. + and classifies those tokens as concrete repository paths or globs. + Get-ContractIdentifier was relocated to BlastRadiusNormalization.psm1 so this + module stays inside the 500-line limit (issue #489); the three module-scoped + heading variables it read travelled with it, because $script: scope is per + module. The Python module remains the authoritative reference implementation. This module is one half of a two-language mirror; it never imports validator @@ -53,17 +56,27 @@ $script:PlanTaskPattern = [regex]::new( # on its own line, from producing spurious spans. $script:InlineCodeSpanPattern = [regex]::new('`([^`]+)`') -# Markdown ATX heading pattern used to locate spec interface sections. -$script:HeadingPattern = [regex]::new('^(?#{1,6}) (?.+)$') - # Top-level directories of this repository. A token starting with one of these is -# accepted without needing a recognized extension, which admits directory-shaped -# tokens and ** globs. +# accepted without needing a recognized extension, which admits ** globs naming a +# subtree. artifacts/ is deliberately absent (issue #489): the process-artifact +# tree is read by mandate rather than written by a work item, so admitting +# artifacts/** as a subtree claim made unrelated items contend. $script:KnownTopLevelSegment = @( 'scripts/', 'tests/', 'docs/', 'config/', 'schemas/', 'packages/', - 'extensions/', '.claude/', '.codex/', '.github/', '.agents/', 'artifacts/' + 'extensions/', '.claude/', '.codex/', '.github/', '.agents/' ) +# A file citation may carry a trailing line reference such as :90. The suffix is +# stripped before the extension test so a line-anchored citation keeps the +# acceptance its unanchored form has; the token itself is recorded verbatim. +$script:LineSuffixPattern = [regex]::new(':\d+$') + +# Documentation-corpus root and the index, counted after that prefix, of the +# segment that names one feature folder. A glob whose wildcard reaches this +# segment or any earlier one claims every feature folder in the corpus. +$script:FeatureCorpusPrefix = 'docs/features/' +$script:FeatureFolderSegmentIndex = 1 + # Fallback acceptance rule: a token shaped <segment>/.../<name>.<ext> counts as a # repository path when its final component carries one of these extensions. $script:RecognizedPathExtension = [System.Collections.Generic.HashSet[string]]::new( @@ -74,19 +87,11 @@ $script:RecognizedPathExtension = [System.Collections.Generic.HashSet[string]]:: ), [StringComparer]::Ordinal) -# A spec section qualifies as an interface section when its heading, or the -# heading of an ancestor section, contains one of these words. -$script:ContractHeadingKeyword = @('API', 'Interface', 'Contract', 'Surface') - # Classification vocabulary for accepted path tokens. Concrete entries take part # in exact-match checks; glob entries cannot and are matched by pattern. $script:PathKindConcrete = 'concrete' $script:PathKindGlob = 'glob' -# Heading depth sentinel standing in for the Python `qualifying_depth is None` -# state: markdown heading levels are 1..6, so 0 can never be a real level. -$script:NoQualifyingHeadingDepth = 0 - function ConvertTo-NormalizedLine { <# @@ -228,6 +233,60 @@ function Get-InlineCodeToken { return @($token.ToArray()) } +function Test-MultipleFeatureFolderSpan { + <# + .SYNOPSIS + Report whether a glob claims more than one documentation feature folder. + + .DESCRIPTION + Port of spans_multiple_feature_folders. The documentation corpus is laid + out as docs/features/<bucket>/<feature-folder>/..., so a glob whose + wildcard occupies or truncates the feature-folder segment claims every + feature folder in the corpus. That made two unrelated work items contend + purely because both wrote documentation (issue #489). A glob carrying a + complete, wildcard-free feature-folder segment claims one folder and is + retained. + + .PARAMETER Token + A wildcard-bearing token already accepted by the shape rules of + Get-PathTokenKind. + + .OUTPUTS + System.Boolean. True when the token is rooted in the documentation corpus + and its wildcard reaches the feature-folder segment or any earlier one. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string] $Token + ) + + if (-not $Token.StartsWith($script:FeatureCorpusPrefix, + [System.StringComparison]::Ordinal)) { + return $false + } + + $segment = @($Token.Substring($script:FeatureCorpusPrefix.Length) -split '/') + + # A token that stops at or before the feature-folder segment has had that + # segment truncated away by the wildcard, so it spans the whole corpus. + if ($segment.Count -le $script:FeatureFolderSegmentIndex) { + return $true + } + + # Every segment up to and including the feature-folder name must be a literal + # for the claim to resolve to exactly one folder. + for ($index = 0; $index -le $script:FeatureFolderSegmentIndex; $index++) { + if ($segment[$index].IndexOf('*') -ge 0) { + return $true + } + } + + return $false +} + function Get-PathTokenKind { <# .SYNOPSIS @@ -287,14 +346,30 @@ function Get-PathTokenKind { } # Read the final component's extension for the fallback acceptance rule; a - # component with no dot (a directory name or **) has no extension. - $finalComponent = $Token.Substring($Token.LastIndexOf('/') + 1) + # component with no dot (a directory name or **) has no extension. A trailing + # line reference is stripped first so file.md:90 reads as md rather than as + # the unrecognized extension md:90. + $finalComponent = $script:LineSuffixPattern.Replace( + $Token.Substring($Token.LastIndexOf('/') + 1), '') $extension = '' $dotIndex = $finalComponent.LastIndexOf('.') if ($dotIndex -ge 0) { $extension = $finalComponent.Substring($dotIndex + 1).ToLowerInvariant() } + $hasExtension = $script:RecognizedPathExtension.Contains($extension) + + # A wildcard-free token must name a file, not a directory (issue #489). A + # directory-shaped token such as scripts/dev_tools is a location reference, + # not a write claim, and admitting it made every item touching anything under + # that directory contend at the path level. + if ($Token.IndexOf('*') -lt 0) { + if ($hasExtension) { + return $script:PathKindConcrete + } + return $null + } + $hasKnownSegment = $false foreach ($segment in $script:KnownTopLevelSegment) { if ($Token.StartsWith($segment, [System.StringComparison]::Ordinal)) { @@ -303,18 +378,23 @@ function Get-PathTokenKind { } } - # Failing both shape rules means the token is prose or a non-path expression - # that merely contains a separator, so it is dropped. - if (-not $hasKnownSegment -and -not $script:RecognizedPathExtension.Contains($extension)) { + # A wildcard-bearing token must still satisfy one of the two documented shape + # rules; failing both means the token is prose or a non-path expression that + # merely contains a separator, so it is dropped. + if (-not $hasKnownSegment -and -not $hasExtension) { + return $null + } + + # A documentation glob spanning the whole feature corpus is a cross-corpus + # claim rather than a write claim, so it is dropped before it can become a + # radius entry. + if (Test-MultipleFeatureFolderSpan -Token $Token) { return $null } # An accepted token carrying a wildcard names a set of files, so it cannot # take part in concrete exact-match comparisons and is recorded as a glob. - if ($Token.IndexOf('*') -ge 0) { - return $script:PathKindGlob - } - return $script:PathKindConcrete + return $script:PathKindGlob } function Get-PathFromLine { @@ -407,84 +487,12 @@ function Get-PlanPaths { return @(Get-PathFromLine -Line $allLine.ToArray() -RootSurface $RootSurface) } -function Get-ContractIdentifier { - <# - .SYNOPSIS - Extract contract identifiers from a spec's interface sections. - - .DESCRIPTION - Port of extract_contract_identifiers. Implements the contracts level of - the radius model: exported symbols, schema names, and CLI identifiers - named in inline code inside sections whose heading, or an ancestor - heading, contains API, Interface, Contract, or Surface. Markdown sections - nest, so a heading deeper than the innermost qualifying heading stays - inside that section and inherits its qualification; a heading at or above - that level ends the section and is judged on its own title. - - .PARAMETER SpecText - Full feature spec.md document text; may be empty. - - .OUTPUTS - System.Object[]. Identifiers, deduplicated and ordinally sorted. Tokens - containing a separator are excluded as path references. - #> - [CmdletBinding()] - [OutputType([System.Object[]])] - param( - [Parameter(Mandatory = $true)] - [AllowEmptyString()] - [string] $SpecText - ) - - $identifier = [System.Collections.Generic.List[string]]::new() - $qualifyingDepth = $script:NoQualifyingHeadingDepth - - foreach ($line in @(ConvertTo-NormalizedLine -Text $SpecText)) { - $headingMatch = $script:HeadingPattern.Match($line) - - # A heading changes the section context and contributes no identifiers of - # its own, so each heading is handled and the line is then skipped. - if ($headingMatch.Success) { - $headingLevel = $headingMatch.Groups['hashes'].Value.Length - if ($qualifyingDepth -ne $script:NoQualifyingHeadingDepth -and - $headingLevel -gt $qualifyingDepth) { - continue - } - - $headingTitle = $headingMatch.Groups['title'].Value - $qualifyingDepth = $script:NoQualifyingHeadingDepth - foreach ($keyword in $script:ContractHeadingKeyword) { - if ($headingTitle.IndexOf($keyword, [System.StringComparison]::Ordinal) -ge 0) { - $qualifyingDepth = $headingLevel - break - } - } - continue - } - - if ($qualifyingDepth -eq $script:NoQualifyingHeadingDepth) { - continue - } - - # Inside a qualifying section an inline-code token without a separator is - # a contract identifier; a token with one is a path reference and is - # recorded at the paths level instead. - foreach ($token in @(Get-InlineCodeToken -Line $line)) { - if ($token.IndexOf('/') -lt 0) { - $identifier.Add($token) - } - } - } - - return @(Get-OrdinalSortedEntry -Entry $identifier.ToArray()) -} - Export-ModuleMember -Function ` Get-OrdinalSortedEntry, ` ConvertTo-NormalizedLine, ` Get-PlanLineScan, ` Get-InlineCodeToken, ` + Test-MultipleFeatureFolderSpan, ` Get-PathTokenKind, ` Get-PathFromLine, ` - Get-PlanPaths, ` - Get-ContractIdentifier + Get-PlanPaths diff --git a/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 b/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 new file mode 100644 index 000000000..bbc69567c --- /dev/null +++ b/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 @@ -0,0 +1,295 @@ +<# +.SYNOPSIS + Blast-radius normalization: contract extraction, module resolution, and the + read-by-mandate exclusion. + +.DESCRIPTION + Destination-runtime PowerShell port of + scripts/dev_tools/_blast_radius_normalization.py, plus the two functions + relocated here so their source modules stay inside the 500-line limit + (issue #489): Get-ContractIdentifier from BlastRadiusExtraction.psm1 and + Resolve-BlastRadiusModule from BlastRadiusConfig.psm1. + + The Python modules remain the authoritative reference implementation. This + module is one half of a two-language mirror; it never imports validator + logic. Every function is pure: no filesystem, subprocess, network, or + wall-clock access, and no input is mutated. + + Parity notes for maintainers: + - Get-ContractIdentifier carries the module-scoped variables it reads + ($script:HeadingPattern, $script:ContractHeadingKeyword, and + $script:NoQualifyingHeadingDepth). PowerShell scopes $script: per module, + so a function-only relocation would resolve them to $null at runtime. + - Test-MandateRead applies exact ordinal equality first, which is the only + rule that can settle a glob entry, then glob containment for a concrete + entry only. A glob entry is never tested for containment in another glob, + matching matches_mandate_read. + - 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. +#> + +Set-StrictMode -Version Latest + +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 + +# Markdown ATX heading pattern used to locate spec interface sections. Relocated +# with Get-ContractIdentifier: $script: scope is per module, so the variable must +# travel with its only consumer. +$script:HeadingPattern = [regex]::new('^(?<hashes>#{1,6}) (?<title>.+)$') + +# A spec section qualifies as an interface section when its heading, or the +# heading of an ancestor section, contains one of these words. +$script:ContractHeadingKeyword = @('API', 'Interface', 'Contract', 'Surface') + +# Heading depth sentinel standing in for the Python `qualifying_depth is None` +# state: markdown heading levels are 1..6, so 0 can never be a real level. +$script:NoQualifyingHeadingDepth = 0 + +# A contract identifier names something callable or referenceable, so it must +# carry at least one ASCII letter. Punctuation-only tokens such as -> or a bare +# digit are notation from an interface example rather than a contract, and +# admitting them made unrelated specs contend (issue #489). +$script:ContractLetterPattern = [regex]::new('[A-Za-z]') + + +function Get-ContractIdentifier { + <# + .SYNOPSIS + Extract contract identifiers from a spec's interface sections. + + .DESCRIPTION + Port of extract_contract_identifiers. Implements the contracts level of + the radius model: exported symbols, schema names, and CLI identifiers + named in inline code inside sections whose heading, or an ancestor + heading, contains API, Interface, Contract, or Surface. Markdown sections + nest, so a heading deeper than the innermost qualifying heading stays + inside that section and inherits its qualification; a heading at or above + that level ends the section and is judged on its own title. + + .PARAMETER SpecText + Full feature spec.md document text; may be empty. + + .OUTPUTS + System.Object[]. Identifiers, deduplicated and ordinally sorted. Tokens + containing a separator are excluded as path references, as are tokens + carrying no ASCII letter. + #> + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string] $SpecText + ) + + $identifier = [System.Collections.Generic.List[string]]::new() + $qualifyingDepth = $script:NoQualifyingHeadingDepth + + foreach ($line in @(ConvertTo-NormalizedLine -Text $SpecText)) { + $headingMatch = $script:HeadingPattern.Match($line) + + # A heading changes the section context and contributes no identifiers of + # its own, so each heading is handled and the line is then skipped. + if ($headingMatch.Success) { + $headingLevel = $headingMatch.Groups['hashes'].Value.Length + if ($qualifyingDepth -ne $script:NoQualifyingHeadingDepth -and + $headingLevel -gt $qualifyingDepth) { + continue + } + + $headingTitle = $headingMatch.Groups['title'].Value + $qualifyingDepth = $script:NoQualifyingHeadingDepth + foreach ($keyword in $script:ContractHeadingKeyword) { + if ($headingTitle.IndexOf($keyword, [System.StringComparison]::Ordinal) -ge 0) { + $qualifyingDepth = $headingLevel + break + } + } + continue + } + + if ($qualifyingDepth -eq $script:NoQualifyingHeadingDepth) { + continue + } + + # Inside a qualifying section an inline-code token without a separator is + # a contract identifier; a token with one is a path reference and is + # recorded at the paths level instead. A token carrying no ASCII letter + # is notation, not an identifier, and is dropped (issue #489). + foreach ($token in @(Get-InlineCodeToken -Line $line)) { + if ($token.IndexOf('/') -lt 0 -and + $script:ContractLetterPattern.IsMatch($token)) { + $identifier.Add($token) + } + } + } + + return @(Get-OrdinalSortedEntry -Entry $identifier.ToArray()) +} + +function Resolve-BlastRadiusModule { + <# + .SYNOPSIS + Resolve path entries to the module names of the truth-table map. + + .DESCRIPTION + Port of resolve_modules. A module joins the radius as soon as one of its + globs covers one entry, so the search stops at the first hit per module. + A path matching no glob resolves to no module. + + .PARAMETER PathEntry + Concrete paths and globs of a radius. An empty collection is accepted. + + .PARAMETER Config + Parsed config/blast-radius.json. + + .OUTPUTS + System.Object[]. Matched module names, deduplicated and ordinally sorted. + #> + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]] $PathEntry, + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Config + ) + + $matched = [System.Collections.Generic.List[string]]::new() + foreach ($pair in @(Get-ConfigModuleEntry -Config $Config)) { + foreach ($pattern in $pair['globs']) { + $hit = $false + foreach ($entry in $PathEntry) { + if (Test-GlobMatch -Pattern $pattern -Candidate $entry) { + $matched.Add([string]$pair['name']) + $hit = $true + break + } + } + if ($hit) { + break + } + } + } + + return @(Get-OrdinalSortedEntry -Entry $matched.ToArray()) +} + + +function Test-MandateRead { + <# + .SYNOPSIS + Report whether one radius entry is a read-by-mandate citation. + + .DESCRIPTION + Port of matches_mandate_read. Exact ordinal equality settles both a + concrete path listed verbatim and a glob entry that repeats a configured + glob character for character. Glob containment then covers a concrete + path falling inside a configured subtree pattern such as artifacts/**. A + glob entry is deliberately never tested for containment in another glob: + deciding whether one pattern subsumes another is not a comparison this + repository's glob vocabulary supports, and guessing would silently drop a + genuine claim. + + .PARAMETER Entry + One harvested or extracted radius entry: a concrete repository-relative + path or a glob pattern. + + .PARAMETER MandateRead + Configured mandate-read patterns from Get-ConfigMandateRead. An empty + collection matches nothing. + + .OUTPUTS + System.Boolean. True when the entry must be excluded from contention. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string] $Entry, + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]] $MandateRead + ) + + foreach ($pattern in $MandateRead) { + if ([string]::Equals($Entry, $pattern, [System.StringComparison]::Ordinal)) { + return $true + } + } + + # A glob entry that did not match exactly is left alone; only a concrete path + # is tested for containment in a configured subtree pattern. + if (Test-GlobEntry -Entry $Entry) { + return $false + } + + foreach ($pattern in $MandateRead) { + if (Test-GlobMatch -Pattern $pattern -Candidate $Entry) { + return $true + } + } + + return $false +} + +function Get-NonMandateReadEntry { + <# + .SYNOPSIS + Drop every read-by-mandate citation from a collection of radius entries. + + .DESCRIPTION + Port of exclude_mandate_reads. Every agent is instructed to read the + policy rules, the tier map, and the process artifacts before doing any + work, so a citation of one of those paths is evidence that the author + obeyed the reading order rather than evidence that the change will write + the file. Counting such citations as contention made thematically + unrelated work items collide (issue #489). + + .PARAMETER Entry + Harvested or extracted radius entries. An empty collection is accepted. + + .PARAMETER MandateRead + Configured mandate-read patterns from Get-ConfigMandateRead. An empty + collection excludes nothing, so the returned content equals the input + content. + + .OUTPUTS + System.Object[]. Surviving entries, deduplicated and ordinally sorted. + #> + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]] $Entry, + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]] $MandateRead + ) + + $survivor = [System.Collections.Generic.List[string]]::new() + foreach ($candidate in $Entry) { + if (-not (Test-MandateRead -Entry $candidate -MandateRead $MandateRead)) { + $survivor.Add($candidate) + } + } + + return @(Get-OrdinalSortedEntry -Entry $survivor.ToArray()) +} + +Export-ModuleMember -Function ` + Get-ContractIdentifier, ` + Resolve-BlastRadiusModule, ` + Test-MandateRead, ` + Get-NonMandateReadEntry diff --git a/.claude/lib/blast-radius/BlastRadiusValidation.psm1 b/.claude/lib/blast-radius/BlastRadiusValidation.psm1 index 6e4cd8611..d57e3dfc1 100644 --- a/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +++ b/.claude/lib/blast-radius/BlastRadiusValidation.psm1 @@ -37,6 +37,7 @@ Set-StrictMode -Version Latest 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 # Finding vocabulary. These strings are contract literals consumed by the # downstream parallel schema and planner features. @@ -348,9 +349,14 @@ function Test-BlastRadius { # The root-surface set comes from the same -Config value that V1 and V2 use # below to resolve modules and shared surfaces, and from the same reader # Get-BlastRadius calls. That shared source is what keeps a derived radius - # passing V1 and V2 against its own plan (issue #452). - $planPath = [string[]]@(Get-PlanPaths -PlanText $PlanText ` - -RootSurface ([string[]]@(Get-ConfigRootSurface -Config $Config))) + # passing V1 and V2 against its own plan (issue #452). The mandate-read + # exclusion is applied here for the same reason: the derivation harvest drops + # those citations, so V1 and V2 must not then demand that the radius cover + # them (issue #489). + $planPath = [string[]]@(Get-NonMandateReadEntry -MandateRead ( + [string[]]@(Get-ConfigMandateRead -Config $Config)) -Entry ( + [string[]]@(Get-PlanPaths -PlanText $PlanText ` + -RootSurface ([string[]]@(Get-ConfigRootSurface -Config $Config))))) $planConcrete = [string[]]@(Get-ConcreteEntry -Entry $planPath) $finding = [System.Collections.Generic.List[hashtable]]::new() diff --git a/.claude/lib/codex-routing/CodexDeployment.psm1 b/.claude/lib/codex-routing/CodexDeployment.psm1 new file mode 100644 index 000000000..04c85941e --- /dev/null +++ b/.claude/lib/codex-routing/CodexDeployment.psm1 @@ -0,0 +1,312 @@ +<# +.SYNOPSIS + Codex deployment resolver, ported from the Python reference implementation. + +.DESCRIPTION + Destination-runtime PowerShell port of + `scripts/dev_tools/resolve_codex_deployment.py`. It resolves the exact Codex + deployment agent, model, and reasoning effort for one delegation from the + logical agent, the assessed complexity band, the execution context, and the + orchestration complexity ceiling. C3 defaults to Terra/high and elevates to + Sol/high only for an epic child or a C4 orchestration ceiling; the epic + planner and epic orchestrator personas are always forced to Sol/ultra. No + model alias and no silent fallback is accepted. + + SINGLE-IMPLEMENTATION RULE. The orchestrator-state U6.X checks + (`OrchestratorStateCodexModelReceipts.psm1`) MUST call `Resolve-CodexDeployment` + from this module. They must never re-implement the profile table, the C3 + overlay rule, or the forced-persona rule. This module is the one PowerShell + implementation of the Codex deployment axis. + + Every constant below is pinned to `config/orchestration-routing.json` + (`codex_model_policy`) by a static config-parity check in the Python + reference. Following the `ModelRouting.psm1` pattern, the values are hard-coded + here and never read from disk, because this module is pushed down to consumer + repositories that do not receive `config/orchestration-routing.json`. + + Error surface, relied on by the U6.X receipt checks: + - [System.ArgumentException] is the ValueError-equivalent surface. Its + Message text reproduces the Python message verbatim, including Python + tuple and repr() rendering, because inventory row U6.X5 interpolates the + exception text into its error string. + - [System.InvalidOperationException] is the ModelUnavailableError-equivalent + surface. The Python receipt validator catches only ValueError, so this + exception deliberately does NOT satisfy the U6.X5 branch; it can only be + raised when a caller supplies an availability set, which the checks never do. + + The function is pure: it reads no file, starts no process, and never mutates + its input. +#> + +Set-StrictMode -Version Latest + +# The complexity-band vocabulary, ordered lowest to highest. Pinned to BAND_ORDER +# in scripts/dev_tools/compute_complexity_floor.py. +$script:BAND_ORDER = @('C1', 'C2', 'C3', 'C4') + +# Rendered form of the Python BAND_ORDER tuple, used verbatim inside the +# ValueError-equivalent message so the ported text matches character for character. +$script:BAND_ORDER_PYTHON_TUPLE = "('C1', 'C2', 'C3', 'C4')" + +# The three permitted execution contexts, and the sorted tuple rendering the +# Python message interpolates. +$script:VALID_EXECUTION_CONTEXTS = @('standalone', 'epic_preparation_child', 'epic_execution_child') +$script:VALID_EXECUTION_CONTEXTS_PYTHON_TUPLE = "('epic_execution_child', 'epic_preparation_child', 'standalone')" + +# The execution contexts that elevate a C3 delegation, and the ceiling band that +# does so independently of context. +$script:C3_ELEVATED_EXECUTION_CONTEXTS = @('epic_preparation_child', 'epic_execution_child') +$script:C3_ELEVATED_CEILING = 'C4' +$script:C3_BAND = 'C3' + +# The agent families for which a generated per-band deployment agent exists, and +# the logical-to-family alias map. +$script:GENERATED_AGENT_FAMILIES = @( + 'orchestrator', + 'atomic-planner', + 'atomic-executor', + 'feature-reviewer', + 'task-researcher', + 'prd-feature', + 'pr-author', + 'python-typed-engineer', + 'powershell-typed-engineer', + 'csharp-typed-engineer', + 'typescript-engineer' +) +$script:LOGICAL_AGENT_ALIASES = @{ 'feature-review' = 'feature-reviewer' } + +# The per-band deployment profiles (suffix, model, reasoning effort). +$script:BASE_PROFILES = @{ + C1 = @{ suffix = 'c1'; model = 'gpt-5.6-luna'; model_reasoning_effort = 'low' } + C2 = @{ suffix = 'c2'; model = 'gpt-5.6-terra'; model_reasoning_effort = 'medium' } + C3 = @{ suffix = 'c3'; model = 'gpt-5.6-terra'; model_reasoning_effort = 'high' } + C4 = @{ suffix = 'c4'; model = 'gpt-5.6-sol'; model_reasoning_effort = 'max' } +} + +# The profile a C3 delegation elevates to when the overlay applies. +$script:C3_ELEVATED_PROFILE = @{ suffix = 'c3-elevated'; model = 'gpt-5.6-sol'; model_reasoning_effort = 'high' } + +# The personas whose profile is forced regardless of band, context, or ceiling. +# Their deployment agent is the logical agent itself (empty suffix). +$script:FORCED_PERSONA_PROFILES = @{ + 'epic-planner' = @{ suffix = ''; model = 'gpt-5.6-sol'; model_reasoning_effort = 'ultra' } + 'epic-orchestrator' = @{ suffix = ''; model = 'gpt-5.6-sol'; model_reasoning_effort = 'ultra' } +} + + +function Assert-CodexBand { + <# + .SYNOPSIS + Return a valid complexity band or throw the field-specific ValueError text. + .DESCRIPTION + Private helper mirroring _validate_band. The thrown message reproduces the + Python text exactly, including the tuple rendering of BAND_ORDER and the + repr() rendering of the offending value, because U6.X5 interpolates it. + .PARAMETER Value + The candidate band value. + .PARAMETER FieldName + The field name to name in the message. + .OUTPUTS + System.String - the validated band. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string] $Value, + + [Parameter(Mandatory = $true)] + [string] $FieldName + ) + + if ($script:BAND_ORDER -cnotcontains $Value) { + throw [System.ArgumentException]::new( + "$FieldName must be one of $($script:BAND_ORDER_PYTHON_TUPLE), found '$Value'." + ) + } + return $Value +} + +function Assert-CodexExecutionContext { + <# + .SYNOPSIS + Return a valid execution context or throw the ValueError-equivalent text. + .DESCRIPTION + Private helper mirroring _validate_context, reproducing the Python message + including the sorted-tuple rendering of the permitted contexts. + .PARAMETER Value + The candidate execution-context value. + .OUTPUTS + System.String - the validated execution context. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string] $Value + ) + + if ($script:VALID_EXECUTION_CONTEXTS -cnotcontains $Value) { + throw [System.ArgumentException]::new( + "execution_context must be one of $($script:VALID_EXECUTION_CONTEXTS_PYTHON_TUPLE), found '$Value'." + ) + } + return $Value +} + +function Get-CodexC3OverlayReason { + <# + .SYNOPSIS + Return the deterministic C3 elevation reason, or $null when none applies. + .DESCRIPTION + Private helper mirroring _select_c3_overlay_reason. Both conditions + together report the combined reason; the ordering matters because the + combined reason must win over either single reason. + .PARAMETER ExecutionContext + The validated execution context. + .PARAMETER OrchestrationComplexityCeiling + The validated orchestration ceiling band. + .OUTPUTS + System.String - the reason, or $null. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string] $ExecutionContext, + + [Parameter(Mandatory = $true)] + [string] $OrchestrationComplexityCeiling + ) + + $epicContext = $script:C3_ELEVATED_EXECUTION_CONTEXTS -ccontains $ExecutionContext + $c4Ceiling = ($OrchestrationComplexityCeiling -ceq $script:C3_ELEVATED_CEILING) + + if ($epicContext -and $c4Ceiling) { return 'epic_context_and_c4_ceiling' } + if ($epicContext) { return 'epic_context' } + if ($c4Ceiling) { return 'c4_orchestration_ceiling' } + return $null +} + +function Resolve-CodexDeployment { + <# + .SYNOPSIS + Resolve the exact Codex deployment agent, model, and reasoning effort. + .DESCRIPTION + Faithful PowerShell port of resolve_codex_deployment + (scripts/dev_tools/resolve_codex_deployment.py). Validates both bands and + the execution context, rejects a ceiling below the band, then selects the + profile: a forced persona wins outright; otherwise a C3 delegation may + elevate under the overlay rule and every other band reads the base table. + The deployment agent name is the resolved family plus the profile suffix. + .PARAMETER LogicalAgent + The logical agent name being delegated to. + .PARAMETER ComplexityBand + The assessed complexity band, one of C1..C4. + .PARAMETER ExecutionContext + One of standalone, epic_preparation_child, epic_execution_child. + .PARAMETER OrchestrationComplexityCeiling + The orchestration ceiling band, one of C1..C4, at or above ComplexityBand. + .PARAMETER AvailableModel + Optional availability set. When supplied and the routed model is absent + from it, the resolver throws rather than falling back to another model. + .OUTPUTS + System.Collections.Hashtable with the nine resolved keys: logical_agent, + deployment_agent, complexity_band, execution_context, + orchestration_complexity_ceiling, c3_overlay_applied, c3_overlay_reason, + model, model_reasoning_effort. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string] $LogicalAgent, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string] $ComplexityBand, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string] $ExecutionContext, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string] $OrchestrationComplexityCeiling, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [string[]] $AvailableModel = $null + ) + + $band = Assert-CodexBand -Value $ComplexityBand -FieldName 'complexity_band' + $ceiling = Assert-CodexBand -Value $OrchestrationComplexityCeiling -FieldName 'orchestration_complexity_ceiling' + $context = Assert-CodexExecutionContext -Value $ExecutionContext + + # The ceiling is an upper bound on the delegation band; a ceiling below the + # band is an inconsistent pair rather than a clamp. + if ($script:BAND_ORDER.IndexOf($band) -gt $script:BAND_ORDER.IndexOf($ceiling)) { + throw [System.ArgumentException]::new( + "orchestration_complexity_ceiling must be greater than or equal to complexity_band, found $ceiling below $band." + ) + } + + # Routing table: a forced persona ignores band, context, and ceiling entirely + # and keeps its own name as the deployment agent; every other agent resolves + # through the family alias map, the C3 overlay rule, and the base profiles. + if ($script:FORCED_PERSONA_PROFILES.ContainsKey($LogicalAgent)) { + $deploymentProfile = $script:FORCED_PERSONA_PROFILES[$LogicalAgent] + $deploymentAgent = $LogicalAgent + $overlayReason = $null + } else { + $deploymentFamily = $LogicalAgent + if ($script:LOGICAL_AGENT_ALIASES.ContainsKey($LogicalAgent)) { + $deploymentFamily = $script:LOGICAL_AGENT_ALIASES[$LogicalAgent] + } + if ($script:GENERATED_AGENT_FAMILIES -cnotcontains $deploymentFamily) { + throw [System.ArgumentException]::new("Unsupported Codex logical agent: '$LogicalAgent'.") + } + + # The overlay is a C3-only rule; every other band reads the base table. + $overlayReason = $null + if ($band -ceq $script:C3_BAND) { + $overlayReason = Get-CodexC3OverlayReason -ExecutionContext $context -OrchestrationComplexityCeiling $ceiling + } + $deploymentProfile = if ($overlayReason) { $script:C3_ELEVATED_PROFILE } else { $script:BASE_PROFILES[$band] } + $deploymentAgent = "$deploymentFamily-$($deploymentProfile['suffix'])" + } + + # An availability set that omits the routed model is a hard failure: silent + # fallback to a different model is prohibited. + if ($null -ne $AvailableModel -and $AvailableModel -cnotcontains $deploymentProfile['model']) { + throw [System.InvalidOperationException]::new( + "model_unavailable: required Codex model '$($deploymentProfile['model'])' is unavailable; silent fallback is prohibited." + ) + } + + return @{ + logical_agent = $LogicalAgent + deployment_agent = $deploymentAgent + complexity_band = $band + execution_context = $context + orchestration_complexity_ceiling = $ceiling + c3_overlay_applied = ($null -ne $overlayReason) + c3_overlay_reason = $overlayReason + model = $deploymentProfile['model'] + model_reasoning_effort = $deploymentProfile['model_reasoning_effort'] + } +} + +# Only the resolver is exported; the validation and overlay helpers are private so +# no consumer can bypass the single entry point. +Export-ModuleMember -Function Resolve-CodexDeployment diff --git a/.claude/lib/codex-routing/CodexTopology.psm1 b/.claude/lib/codex-routing/CodexTopology.psm1 new file mode 100644 index 000000000..70da608cb --- /dev/null +++ b/.claude/lib/codex-routing/CodexTopology.psm1 @@ -0,0 +1,392 @@ +<# +.SYNOPSIS + Codex implementation-topology resolver, ported from the Python reference. + +.DESCRIPTION + Destination-runtime PowerShell port of + `scripts/dev_tools/resolve_codex_topology.py`. It resolves the initial + implementation agent for one change from deterministic scope data: the + languages touched, the production and test file counts, the execution + context, the cross-cutting indicator, and an optional forced root persona. + + Routing summary. A standalone, single-language change inside that language's + canonical direct-mode budget selects the language's typed engineer on the + `small` route. Every escalation condition selects the orchestrator on the + `large` route. An explicit root epic persona selects itself on the `epic` + route. The escalation conditions are evaluated in a fixed precedence order, + and the first match wins: + + epic_child_context -> invalid_estimate -> cross_language -> + unsupported_language -> cross_cutting -> direct_mode_disabled -> + production_budget_exceeded + + SINGLE-IMPLEMENTATION RULE. The orchestrator-state U6.T checks + (`OrchestratorStateCodexTopologyReceipts.psm1`) MUST call + `Resolve-CodexTopology` and read `Get-CodexForcedRootPersona` from this + module. They must never re-implement the language-budget table or the + escalation precedence. This module is the one PowerShell implementation of + the Codex topology axis. + + Every constant below is pinned to `config/orchestration-routing.json` + (`codex_topology_policy`). Following the `ModelRouting.psm1` pattern, the + values are hard-coded here and never read from disk, because this module is + pushed down to consumer repositories that do not receive that config. + + Error surface, relied on by the U6.T receipt checks: + - [System.ArgumentException] is the ValueError-equivalent surface. Its + Message text reproduces the Python message verbatim, including Python + tuple and repr() rendering, because inventory row U6.T10 interpolates + the exception text into its error string. + + Documented divergence: Python raises TypeError (not ValueError) when + `languages` is not iterable, and the receipt validator does not catch + TypeError. This port treats a null Language argument as an empty collection, + which routes to the `unsupported_language` escalation. The difference is + unreachable from the U6.T checks, which reject a non-list `languages` before + the resolver is ever called. + + The function is pure: it reads no file, starts no process, and never mutates + its input. +#> + +Set-StrictMode -Version Latest + +# The three permitted execution contexts, the sorted tuple rendering the Python +# message interpolates, and the two contexts that mark epic child work. +$script:VALID_EXECUTION_CONTEXTS = @('standalone', 'epic_preparation_child', 'epic_execution_child') +$script:VALID_EXECUTION_CONTEXTS_PYTHON_TUPLE = "('epic_execution_child', 'epic_preparation_child', 'standalone')" +$script:EPIC_CHILD_CONTEXTS = @('epic_preparation_child', 'epic_execution_child') +$script:STANDALONE_CONTEXT = 'standalone' + +# The two personas that may be forced as the root of a run, and the logical agent +# every escalation routes to. +$script:FORCED_ROOT_PERSONAS = @('epic-planner', 'epic-orchestrator') +$script:ORCHESTRATOR_LOGICAL_AGENT = 'orchestrator' + +# The per-language direct-mode budgets. A language absent from this table is an +# unsupported language and escalates. +$script:LANGUAGE_BUDGETS = @{ + python = @{ direct_mode_enabled = $true; max_production_files = 3; max_test_files = 3; logical_agent = 'python-typed-engineer' } + powershell = @{ direct_mode_enabled = $true; max_production_files = 2; max_test_files = 3; logical_agent = 'powershell-typed-engineer' } + csharp = @{ direct_mode_enabled = $true; max_production_files = 3; max_test_files = 3; logical_agent = 'csharp-typed-engineer' } + typescript = @{ direct_mode_enabled = $false; max_production_files = 0; max_test_files = 0; logical_agent = 'typescript-engineer' } +} + +# The integral CLR types a JSON integer can deserialize to. A CLR boolean is +# deliberately absent: Python rejects bool where an int is required. +$script:INTEGRAL_TYPES = @([int], [long], [short], [byte]) + + +function Get-CodexForcedRootPersona { + <# + .SYNOPSIS + Return the two personas that may be forced as the root of a run. + .DESCRIPTION + Read-only accessor for FORCED_ROOT_PERSONAS. Exported so the U6.T + root_persona enum check reads the same set the resolver enforces instead + of restating it. + .OUTPUTS + System.String[] - the forced root persona names, in declaration order. + The names are emitted to the pipeline individually, so a caller collects + them with @(...) in the usual way. + #> + [CmdletBinding()] + [OutputType([string[]])] + param() + + return [string[]]@($script:FORCED_ROOT_PERSONAS) +} + +function Test-CodexIntegralValue { + <# + .SYNOPSIS + Report whether a value is an integer, rejecting booleans. + .DESCRIPTION + Private predicate mirroring the Python guard + `isinstance(value, bool) or not isinstance(value, int)`. Python's bool is + a subclass of int, so the reference rejects it explicitly; this port + compares the exact CLR type so no boolean satisfies the test. + .PARAMETER Value + The candidate value. May be $null. + .OUTPUTS + System.Boolean - $true only for a non-boolean integral value. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value -or $Value -is [bool]) { return $false } + foreach ($integralType in $script:INTEGRAL_TYPES) { + if ($Value.GetType() -eq $integralType) { return $true } + } + return $false +} + +function Get-CodexNormalizedLanguage { + <# + .SYNOPSIS + Return unique, lowercased language names in stable ordinal order. + .DESCRIPTION + Private helper mirroring _normalize_languages. Every element must be a + non-blank string; anything else throws the ValueError-equivalent. The + result is deduplicated and ordinally sorted so the resolved receipt is + order-independent, matching Python's sorted(set(...)). + .PARAMETER Language + The raw language collection. A null value is treated as empty. + .OUTPUTS + System.String[] - the normalized language names. Declared additionally as + System.Object[] because the array is emitted through the unary-comma form + that stops PowerShell unrolling it. + #> + [CmdletBinding()] + [OutputType([string[]], [object[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Language + ) + + $normalized = [System.Collections.Generic.HashSet[string]]::new() + + # Reject the whole collection on the first malformed member so a partially + # valid language list can never route a delegation. + foreach ($item in @($Language)) { + if (-not ($item -is [string]) -or [string]::IsNullOrWhiteSpace([string]$item)) { + throw [System.ArgumentException]::new('languages must contain non-empty strings.') + } + [void]$normalized.Add(([string]$item).Trim().ToLowerInvariant()) + } + + # Ordinal ordering matches Python's sorted() over the same names; PowerShell's + # culture-aware Sort-Object would not be guaranteed to. The unary comma stops + # PowerShell from unrolling the array on return, which would otherwise turn a + # single-language result into a bare string and a zero-language result into + # nothing at all. + $sorted = [string[]]@($normalized) + [Array]::Sort($sorted, [System.StringComparer]::Ordinal) + return , $sorted +} + +function Get-CodexEscalationReceipt { + <# + .SYNOPSIS + Build a large-route orchestrator receipt for one escalation reason. + .DESCRIPTION + Private pure builder mirroring _orchestrator_receipt. Every escalation returns + the same shape and differs only in routing_reason and in whether a + language budget was known at the point of escalation. + .PARAMETER ExecutionContext + The validated execution context. + .PARAMETER Language + The normalized language names. + .PARAMETER ProductionFileCount + The validated production file count. + .PARAMETER TestFileCount + The validated test file count. + .PARAMETER CrossCutting + The validated cross-cutting indicator. + .PARAMETER Reason + The escalation reason recorded in routing_reason. + .PARAMETER Budget + The language budget when one was resolved, otherwise $null. When absent, + both max file counts are reported as null. + .OUTPUTS + System.Collections.Hashtable - the twelve-key topology receipt. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)][string] $ExecutionContext, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]] $Language, + [Parameter(Mandatory = $true)][int] $ProductionFileCount, + [Parameter(Mandatory = $true)][int] $TestFileCount, + [Parameter(Mandatory = $true)][bool] $CrossCutting, + [Parameter(Mandatory = $true)][string] $Reason, + [Parameter(Mandatory = $false)][AllowNull()][hashtable] $Budget = $null + ) + + return @{ + execution_context = $ExecutionContext + languages = $Language + production_file_count = $ProductionFileCount + test_file_count = $TestFileCount + cross_cutting = $CrossCutting + root_persona = $null + route = 'large' + topology = 'orchestrator' + logical_agent = $script:ORCHESTRATOR_LOGICAL_AGENT + routing_reason = $Reason + max_production_files = $(if ($null -ne $Budget) { $Budget['max_production_files'] } else { $null }) + max_test_files = $(if ($null -ne $Budget) { $Budget['max_test_files'] } else { $null }) + } +} + +function Resolve-CodexTopology { + <# + .SYNOPSIS + Resolve the initial Codex implementation agent from scope data. + .DESCRIPTION + Faithful PowerShell port of resolve_codex_topology + (scripts/dev_tools/resolve_codex_topology.py). Validates the execution + context, the language collection, the two file counts, and the + cross-cutting indicator, then applies the forced-root-persona branch or + the fixed escalation precedence documented in the module header. + .PARAMETER Language + The languages the change touches. Normalized to unique lowercase names. + .PARAMETER ProductionFileCount + The production file count. Must be a non-boolean integer. + .PARAMETER TestFileCount + The test file count. Must be a non-boolean integer. + .PARAMETER ExecutionContext + One of standalone, epic_preparation_child, epic_execution_child. + .PARAMETER CrossCutting + Whether the change is cross-cutting. Must be a boolean. + .PARAMETER RootPersona + An optional forced root persona, which requires a standalone context. + .OUTPUTS + System.Collections.Hashtable with the twelve resolved keys: + execution_context, languages, production_file_count, test_file_count, + cross_cutting, root_persona, route, topology, logical_agent, + routing_reason, max_production_files, max_test_files. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Language, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $ProductionFileCount, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $TestFileCount, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string] $ExecutionContext, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [object] $CrossCutting = $false, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [object] $RootPersona = $null + ) + + if ($script:VALID_EXECUTION_CONTEXTS -cnotcontains $ExecutionContext) { + throw [System.ArgumentException]::new( + "execution_context must be one of $($script:VALID_EXECUTION_CONTEXTS_PYTHON_TUPLE), found '$ExecutionContext'." + ) + } + $context = $ExecutionContext + $languages = Get-CodexNormalizedLanguage -Language $Language + + # Counts are validated before any routing so a malformed estimate can never + # be silently coerced into a route decision. + if (-not (Test-CodexIntegralValue -Value $ProductionFileCount)) { + throw [System.ArgumentException]::new('production_file_count must be an integer.') + } + if (-not (Test-CodexIntegralValue -Value $TestFileCount)) { + throw [System.ArgumentException]::new('test_file_count must be an integer.') + } + if (-not ($CrossCutting -is [bool])) { + throw [System.ArgumentException]::new('cross_cutting must be a boolean.') + } + $productionCount = [int]$ProductionFileCount + $testCount = [int]$TestFileCount + $isCrossCutting = [bool]$CrossCutting + + # A forced root persona short-circuits all escalation logic and selects + # itself, but only from a standalone context. + if ($null -ne $RootPersona) { + if (-not ($RootPersona -is [string]) -or ($script:FORCED_ROOT_PERSONAS -cnotcontains [string]$RootPersona)) { + throw [System.ArgumentException]::new("Unsupported Codex root persona: '$RootPersona'.") + } + if ($context -cne $script:STANDALONE_CONTEXT) { + throw [System.ArgumentException]::new('A forced root persona requires standalone context.') + } + return @{ + execution_context = $context + languages = $languages + production_file_count = $productionCount + test_file_count = $testCount + cross_cutting = $isCrossCutting + root_persona = [string]$RootPersona + route = 'epic' + topology = 'epic_persona' + logical_agent = [string]$RootPersona + routing_reason = 'forced_root_persona' + max_production_files = $null + max_test_files = $null + } + } + + # Escalation precedence. The first matching condition wins, so the order of + # these guards is part of the contract, not an implementation detail: epic + # child work escalates before any estimate is trusted, an invalid estimate + # escalates before language analysis, and the budget checks run last because + # they require a resolved single-language budget. + $escalationArgument = @{ + ExecutionContext = $context + Language = $languages + ProductionFileCount = $productionCount + TestFileCount = $testCount + CrossCutting = $isCrossCutting + } + + if ($script:EPIC_CHILD_CONTEXTS -ccontains $context) { + return Get-CodexEscalationReceipt @escalationArgument -Reason 'epic_child_context' + } + if ($productionCount -le 0 -or $testCount -lt 0) { + return Get-CodexEscalationReceipt @escalationArgument -Reason 'invalid_estimate' + } + if ($languages.Count -gt 1) { + return Get-CodexEscalationReceipt @escalationArgument -Reason 'cross_language' + } + if ($languages.Count -ne 1) { + return Get-CodexEscalationReceipt @escalationArgument -Reason 'unsupported_language' + } + if (-not $script:LANGUAGE_BUDGETS.ContainsKey($languages[0])) { + return Get-CodexEscalationReceipt @escalationArgument -Reason 'unsupported_language' + } + + $budget = $script:LANGUAGE_BUDGETS[$languages[0]] + if ($isCrossCutting) { + return Get-CodexEscalationReceipt @escalationArgument -Reason 'cross_cutting' -Budget $budget + } + if (-not $budget['direct_mode_enabled']) { + return Get-CodexEscalationReceipt @escalationArgument -Reason 'direct_mode_disabled' -Budget $budget + } + if ($productionCount -gt $budget['max_production_files']) { + return Get-CodexEscalationReceipt @escalationArgument -Reason 'production_budget_exceeded' -Budget $budget + } + + return @{ + execution_context = $context + languages = $languages + production_file_count = $productionCount + test_file_count = $testCount + cross_cutting = $isCrossCutting + root_persona = $null + route = 'small' + topology = 'typed_engineer' + logical_agent = $budget['logical_agent'] + routing_reason = 'within_language_budget' + max_production_files = $budget['max_production_files'] + max_test_files = $budget['max_test_files'] + } +} + +# The resolver and the forced-root-persona accessor are exported; the validation, +# normalization, and receipt-construction helpers stay private so no consumer can +# bypass the single entry point. +Export-ModuleMember -Function Resolve-CodexTopology, Get-CodexForcedRootPersona diff --git a/.claude/lib/discovery-validation/DiscoveryValidation.psm1 b/.claude/lib/discovery-validation/DiscoveryValidation.psm1 new file mode 100644 index 000000000..d90783a46 --- /dev/null +++ b/.claude/lib/discovery-validation/DiscoveryValidation.psm1 @@ -0,0 +1,500 @@ +<# +.SYNOPSIS + Portable discovery-artifact validation for the Claude enforcement hooks (#475). +.DESCRIPTION + Validates the domain-profile document and the seven schema-governed discovery + artifact types WITHOUT invoking a Python interpreter. Both discovery hooks + previously shelled out to `python -m scripts.dev_tools.validate_discovery_artifacts`; + the `.claude/**` payload ships to destinations with no guaranteed Python, Poetry, + or `scripts/dev_tools`, where that call fails obscurely or blocks everything. + + REQUIRES POWERSHELL 7.4 OR LATER. Schema validation uses `Test-Json -SchemaFile`, + whose JSON Schema Draft 2020-12 support was added in PowerShell 7.4. All seven + schemas under `schemas/discovery/v1/` declare Draft 2020-12 (verified 7 of 7), so + the floor is unavoidable. Verified present in PowerShell 7.6.3 in this environment. + DESTINATION RISK: on PowerShell 7.0-7.3 every entry point fails CLOSED with an + explicit, actionable message naming the required version, the `Test-Json -SchemaFile` + Draft 2020-12 reason, and issue #475. It never degrades silently and never fails + open. The repo standard in `.claude/rules/powershell.md` is "PowerShell 7+", which + is below this floor; that rule file is NOT modified by this change. + + This help is the destination-visible statement of that requirement: the module ships + inside `.claude/**`, and the pushed-down pack holds only `config/` and + `pack-manifests/` beside it, with no pack README to carry the statement. +.NOTES + Issue #475. + + RESULT CONTRACT (defect D-2 avoidance). Success is SILENT: a passing validation + returns `ExitCode = 0` with EMPTY `Output`. Both hooks deny on a non-zero exit code + OR on non-empty output, so the previous Python CLI's success line + ("<type> validation passed: <path>") 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: <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. +#> + +Set-StrictMode -Version Latest + +# Schema-governed artifact types mapped to their filenames under `schemas/discovery/v1/`. +# Verified against `validate_discovery_artifacts.py`: `runtime-scenario`, +# `product-decision`, and `unspecified-behavior` do NOT share their token's stem. +$script:DiscoverySchemaArtifactFile = [ordered]@{ + 'feature-contract' = 'feature-contract.schema.json' + 'coverage-ledger' = 'coverage-ledger.schema.json' + 'runtime-scenario' = 'runtime-characterization-scenario.schema.json' + 'parity-matrix' = 'parity-matrix.schema.json' + 'unspecified-behavior' = 'unspecified-behavior-record.schema.json' + 'product-decision' = 'product-decision-record.schema.json' + 'evidence-reference' = 'evidence-reference.schema.json' +} + +# `Test-Json -SchemaFile` gained Draft 2020-12 support in PowerShell 7.4 and all seven +# schemas declare Draft 2020-12, so this floor is unavoidable. Verified on 7.6.3 here. +$script:MinimumPowerShellVersion = [version]'7.4' + +# TODO(#9001): replace with the finalized field contract once #9001 ships. Mirrors +# `_PLACEHOLDER_REQUIRED_FIELDS` in `validate_discovery_profile.py`, whose own +# TODO(#9001) marks it as the single seam that changes when #9001 lands. +$script:ProfileRequiredField = @('legacy_source_path') + +function Get-DiscoveryRuntimeVersionError { + <# + .SYNOPSIS + Returns the fail-closed message when the host PowerShell is below 7.4, or + $null when the host meets the floor. + .DESCRIPTION + The destination version floor (issue #475). `Test-Json -SchemaFile` gained + JSON Schema Draft 2020-12 support in PowerShell 7.4, and all seven discovery + schemas declare Draft 2020-12, so an older host cannot validate them. + + This runs BEFORE any schema validation so an unsupported host fails with one + explicit, actionable message instead of a raw `Test-Json` parameter error. It + never degrades silently: no partial validation, no fail-open path. + + `PowerShellVersion` is the injectable seam, defaulting to the real + `$PSVersionTable.PSVersion` and only ever READ; nothing here writes + `$PSVersionTable`, so tests pass a version rather than mutating host state. + .OUTPUTS + [string] The fail-closed message, or $null when the host is supported. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $false)] + [version]$PowerShellVersion = $PSVersionTable.PSVersion + ) + + if ($PowerShellVersion -ge $script:MinimumPowerShellVersion) { + return $null + } + + return ( + "Discovery-artifact validation requires PowerShell $($script:MinimumPowerShellVersion) or later " + + "(this host is PowerShell $PowerShellVersion). Reason: schema validation uses " + + "'Test-Json -SchemaFile', whose JSON Schema Draft 2020-12 support was added in PowerShell 7.4, " + + 'and every schema under schemas/discovery/v1/ declares Draft 2020-12. ' + + 'See issue #475. Upgrade the destination host to PowerShell 7.4+ to run this gate.' + ) +} + +function Get-DiscoverySchemaArtifactType { + <# + .SYNOPSIS + Returns the seven schema-governed artifact-type tokens, in dispatch order. + #> + [CmdletBinding()] + [OutputType([object[]])] + param() + + return , @($script:DiscoverySchemaArtifactFile.Keys) +} + +function Get-DiscoverySchemaFileName { + <# + .SYNOPSIS + Returns the schema filename an artifact-type token corresponds to, or $null + when the token is not a schema-governed type. Documentation and artifact-type + validation only: schema RESOLUTION is driven by the artifact's `$schema`. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$ArtifactType + ) + + if ($script:DiscoverySchemaArtifactFile.Contains($ArtifactType)) { + return [string]$script:DiscoverySchemaArtifactFile[$ArtifactType] + } + return $null +} + +function Get-DiscoveryProfileTopLevelKey { + <# + .SYNOPSIS + Extracts the top-level mapping keys of a YAML profile document by line + inspection, without a YAML parser. + .DESCRIPTION + A top-level key is an unindented `key:` line. Blank lines, comment lines, and + the `---`/`...` document markers are skipped. Nested keys are indented and are + therefore ignored, which is exactly the scope the placeholder contract needs. + Returns $null when the document presents no top-level mapping key, which the + caller reports as a non-mapping root. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Text + ) + + $keys = [System.Collections.Generic.List[string]]::new() + $sawContent = $false + + foreach ($line in ($Text -split '\r?\n')) { + $trimmed = $line.Trim() + if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#')) { + continue + } + if ($trimmed -eq '---' -or $trimmed -eq '...') { + continue + } + $sawContent = $true + + # Only unindented lines can carry a top-level key. + if ($line -match '^(?<key>[A-Za-z_][A-Za-z0-9_.-]*)\s*:(\s|$)') { + $keys.Add($Matches['key']) + } + } + + # Content that never presented an unindented `key:` is not a mapping root + # (a sequence root, a bare scalar, or a flow document). + if (-not $sawContent -or $keys.Count -eq 0) { + return $null + } + return , $keys.ToArray() +} + +function Get-DiscoveryProfileValidationError { + <# + .SYNOPSIS + Validates domain-profile document text against the placeholder contract, + mirroring `validate_profile_text` in `validate_discovery_profile.py`. + .DESCRIPTION + Reproduced checks: empty/whitespace-only document; non-mapping root; and the + placeholder required-field set (`legacy_source_path`), reported one error per + absent field. + + NOT reproduced: detection of arbitrary YAML syntax errors. PowerShell ships no + YAML parser and this module must not add a dependency, so the reference's + `Profile document is not valid YAML: <exc>` branch has no direct analogue. The + substitute is fail-CLOSED: any document not presenting an unindented `key:` + line is reported as a non-mapping root rather than accepted, so a malformed + document is still rejected and only the diagnostic wording differs. + .OUTPUTS + [object[]] Zero or more error strings; empty when the document conforms. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Text, + + [Parameter(Mandatory = $false)] + [version]$PowerShellVersion = $PSVersionTable.PSVersion + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # The floor is enforced at EVERY validation entry point, not only the schema path, + # so an unsupported host can never receive a partial pass from this module. + $versionError = Get-DiscoveryRuntimeVersionError -PowerShellVersion $PowerShellVersion + if ($null -ne $versionError) { + $errors.Add($versionError) + return , $errors.ToArray() + } + + if ([string]::IsNullOrWhiteSpace($Text)) { + $errors.Add('Profile document is empty.') + return , $errors.ToArray() + } + + $keys = Get-DiscoveryProfileTopLevelKey -Text $Text + if ($null -eq $keys) { + $errors.Add('Profile document root must be a mapping.') + return , $errors.ToArray() + } + + # Report every absent field so a maintainer can fix the document in one pass. + foreach ($field in $script:ProfileRequiredField) { + if ($keys -notcontains $field) { + $errors.Add("Missing required field: $field.") + } + } + + return , $errors.ToArray() +} + +function Resolve-DiscoverySchemaFilePath { + <# + .SYNOPSIS + Resolves an artifact's `$schema` URI to a local schema file path. + .DESCRIPTION + Mirrors the Python reference's resolution rules for the cases a destination + can service: only a `file://` URI resolves. A scheme-less or non-`file` URI is + rejected, and an `http(s)://` URI is deliberately NOT fetched. + .OUTPUTS + [hashtable] `@{ Path = <string>; Error = <string> }`; exactly one is non-null. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object]$SchemaUri + ) + + if ($SchemaUri -isnot [string] -or [string]::IsNullOrEmpty([string]$SchemaUri)) { + return @{ Path = $null; Error = 'schema resolution failed (missing $schema)' } + } + + $uri = $null + if (-not [System.Uri]::TryCreate([string]$SchemaUri, [System.UriKind]::Absolute, [ref]$uri)) { + # A scheme-less value cannot resolve: these pure text validators receive no + # document path to resolve a relative reference against. + return @{ Path = $null; Error = 'schema resolution failed (Unsupported schema URI scheme: missing)' } + } + + if ($uri.Scheme -ine 'file') { + $message = "schema resolution failed (Unsupported schema URI scheme: $($uri.Scheme))" + return @{ Path = $null; Error = $message } + } + + # LocalPath, not the raw URI path, so a Windows `file:///C:/...` URI resolves. + $localPath = $uri.LocalPath + if (-not (Test-Path -LiteralPath $localPath -PathType Leaf)) { + return @{ Path = $null; Error = "schema resolution failed (Schema file not found: $localPath)" } + } + + return @{ Path = $localPath; Error = $null } +} + +function Get-DiscoverySchemaArtifactValidationError { + <# + .SYNOPSIS + Validates a schema-governed discovery artifact against its declared `$schema`, + mirroring `_validate_against_schema` in `validate_discovery_schema_artifacts.py`. + .DESCRIPTION + Order of checks, matching the reference: JSON parse, object-root check, + `$schema` extraction and resolution, then Draft 2020-12 schema validation via + `Test-Json -SchemaFile`. + .OUTPUTS + [object[]] Zero or more error strings; empty when the artifact conforms. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Text, + + [Parameter(Mandatory = $false)] + [version]$PowerShellVersion = $PSVersionTable.PSVersion + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # Fail closed before any schema work: on an unsupported host `Test-Json -SchemaFile` + # would otherwise fail with an opaque parameter error. + $versionError = Get-DiscoveryRuntimeVersionError -PowerShellVersion $PowerShellVersion + if ($null -ne $versionError) { + $errors.Add($versionError) + return , $errors.ToArray() + } + + $parsed = $null + try { + $parsed = ConvertFrom-Json -InputObject $Text -Depth 100 -ErrorAction Stop + } + catch { + $errors.Add("invalid JSON ($($_.Exception.Message))") + return , $errors.ToArray() + } + + if ($parsed -isnot [System.Management.Automation.PSCustomObject]) { + $errors.Add('JSON root must be an object for validation') + return , $errors.ToArray() + } + + $schemaProperty = $parsed.PSObject.Properties['$schema'] + $schemaUri = if ($null -eq $schemaProperty) { $null } else { $schemaProperty.Value } + + $resolution = Resolve-DiscoverySchemaFilePath -SchemaUri $schemaUri + if ($null -ne $resolution.Error) { + $errors.Add([string]$resolution.Error) + return , $errors.ToArray() + } + + # Test-Json emits one non-terminating error per schema violation; collect them + # all rather than stopping at the first, matching the reference's behavior. + $schemaErrors = $null + $isValid = Test-Json -Json $Text -SchemaFile ([string]$resolution.Path) ` + -ErrorAction SilentlyContinue -ErrorVariable schemaErrors + if (-not $isValid) { + foreach ($schemaError in @($schemaErrors)) { + $errors.Add([string]$schemaError.Exception.Message) + } + # Guarantee a non-empty result so a failure is never reported as a pass. + if ($errors.Count -eq 0) { + $errors.Add('JSON is not valid with the schema') + } + } + + return , $errors.ToArray() +} + +function Get-DiscoveryArtifactValidationError { + <# + .SYNOPSIS + Validates artifact text of any supported type, dispatching on the type token. + .DESCRIPTION + Dispatches `profile` to the placeholder-contract check and each of the seven + schema-governed types to the `$schema`-driven check. An unrecognized token is + reported rather than silently accepted. + .OUTPUTS + [object[]] Zero or more error strings; empty when the artifact conforms. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$ArtifactType, + + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Text, + + [Parameter(Mandatory = $false)] + [version]$PowerShellVersion = $PSVersionTable.PSVersion + ) + + # Assign before returning, never `@(Get-...)`. These helpers emit their array as + # a SINGLE object (the unary-comma no-enumerate idiom), so wrapping the call in + # `@()` collects that one object into a nested one-element array. + if ($ArtifactType -ieq 'profile') { + $profileErrors = Get-DiscoveryProfileValidationError -Text $Text -PowerShellVersion $PowerShellVersion + return , $profileErrors + } + + if ($script:DiscoverySchemaArtifactFile.Contains($ArtifactType)) { + $schemaErrors = Get-DiscoverySchemaArtifactValidationError -Text $Text -PowerShellVersion $PowerShellVersion + return , $schemaErrors + } + + return , @("Unsupported artifact type: $ArtifactType") +} + +function ConvertTo-DiscoveryValidationResult { + <# + .SYNOPSIS + Shapes an error list into the hook seam's `@{ ExitCode; Output }` result. + .DESCRIPTION + The defect D-2 contract in one place: an EMPTY error list yields `ExitCode = 0` + with an EMPTY `Output`. Both hooks deny on a non-zero exit code OR on non-empty + output, so success chatter here would turn a passing validation into a deny. + Kept separate from the on-disk entry point so the contract is unit-testable + without touching the filesystem. + .OUTPUTS + [hashtable] `@{ ExitCode = <int>; Output = <string> }`. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]]$ValidationError + ) + + if (@($ValidationError).Count -eq 0) { + return @{ ExitCode = 0; Output = '' } + } + return @{ ExitCode = 1; Output = ($ValidationError -join [System.Environment]::NewLine) } +} + +function Invoke-DiscoveryArtifactValidation { + <# + .SYNOPSIS + Validates a discovery artifact on disk and returns the hook seam's result shape. + .DESCRIPTION + The entry point both discovery hooks call from `Invoke-DiscoveryValidatorExe`. + `ValidatorArgs` keeps the CLI-style shape the seam already passed: + `@(<artifact-type>, <path>)`. Success returns `ExitCode = 0` with EMPTY + `Output` (defect D-2 avoidance); see `ConvertTo-DiscoveryValidationResult`. + .OUTPUTS + [hashtable] `@{ ExitCode = <int>; Output = <string> }`. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string[]]$ValidatorArgs, + + [Parameter(Mandatory = $false)] + [version]$PowerShellVersion = $PSVersionTable.PSVersion + ) + + if (@($ValidatorArgs).Count -lt 2) { + return @{ ExitCode = 1; Output = 'Discovery validation requires an artifact type and a path.' } + } + + $artifactType = [string]$ValidatorArgs[0] + $path = [string]$ValidatorArgs[1] + + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + return @{ ExitCode = 1; Output = "Artifact not found: $path" } + } + + $text = Get-Content -LiteralPath $path -Raw -ErrorAction Stop + if ($null -eq $text) { + $text = '' + } + + $errors = Get-DiscoveryArtifactValidationError -ArtifactType $artifactType -Text $text ` + -PowerShellVersion $PowerShellVersion + return ConvertTo-DiscoveryValidationResult -ValidationError $errors +} + +Export-ModuleMember -Function @( + 'Get-DiscoveryRuntimeVersionError', + 'Get-DiscoverySchemaArtifactType', + 'Get-DiscoverySchemaFileName', + 'Get-DiscoveryProfileTopLevelKey', + 'Get-DiscoveryProfileValidationError', + 'Resolve-DiscoverySchemaFilePath', + 'Get-DiscoverySchemaArtifactValidationError', + 'Get-DiscoveryArtifactValidationError', + 'ConvertTo-DiscoveryValidationResult', + 'Invoke-DiscoveryArtifactValidation' +) diff --git a/.claude/lib/mermaid/MermaidGrammar.psm1 b/.claude/lib/mermaid/MermaidGrammar.psm1 new file mode 100644 index 000000000..3bf94c213 --- /dev/null +++ b/.claude/lib/mermaid/MermaidGrammar.psm1 @@ -0,0 +1,491 @@ +<# +.SYNOPSIS + Mermaid grammar reference data for the dependency-free structural validator. + +.DESCRIPTION + Data-only module holding the diagram-type keyword allowlist, the per-type + arrow/edge token sets, the deep-checked type set, and the statement-keyword + exemption list used by MermaidLineScanner.psm1 and MermaidValidation.psm1. + Every export is a pure accessor: no filesystem, subprocess, network, or + wall-clock access, and no input is mutated. + + Pinned documentation version: Mermaid 11.17.0. + Source: https://mermaid.js.org/intro/syntax-reference.html + (per-type pages under https://mermaid.js.org/syntax/ ; fetched 2026-08-19). + + Staleness is auditable from this header. Mermaid adds diagram types several + times per year, so the allowlist is a snapshot, not a closed set: an + out-of-date allowlist costs a drift warning, never a false rejection. The one + exception is a near miss, documented at Resolve-MermaidMisspelledKeyword. + + `Verified = $true` means the keyword form was read from the pinned + documentation. `Verified = $false` means the type appears in the 11.x + documentation sidebar but its exact first-line keyword form was not verified, + so it resolves with a drift warning and its body is not judged. + + Only the five deep-checked types (flowchart, sequence, class, state, ER) carry + structural judgement; every other type is keyword-checked only, the fail-open + 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. +#> + +Set-StrictMode -Version Latest + +$script:MermaidGrammarVersion = '11.17.0' +$script:MermaidGrammarSourceUrl = 'https://mermaid.js.org/intro/syntax-reference.html' + +# Statement-keyword lines carry URLs, CSS declarations, and free text. They are +# exempt from BOTH the arrow rules and the bracket-balance rules; only the +# quote-termination rule may still apply. Narrowing or extending this list changes +# false-positive behavior, so it is held in one place. +$script:MermaidStatementKeyword = @( + 'click', 'style', 'classDef', 'linkStyle', 'class', 'accTitle', 'accDescr', 'title' +) + +# Block and declaration keywords that are never edge statements. They are exempt +# from the arrow rules only (their bracket characters still participate in the +# balance count, which is what keeps ER and state composite blocks correct). +# A longer list here can only make the validator more permissive, never stricter. +$script:MermaidNonEdgeKeyword = @( + 'subgraph', 'end', 'direction', 'state', 'note', 'Note', 'participant', + 'actor', 'loop', 'alt', 'else', 'opt', 'par', 'and', 'critical', 'break', + 'rect', 'activate', 'deactivate', 'autonumber', 'box', 'create', 'destroy', + 'namespace', 'cssClass', 'callback', 'link', 'links', 'properties', + 'details', 'section', 'requirement', 'element' +) + +# Types whose statement lines put free-text labels after the first colon. Arrow +# and quote checks apply to the pre-colon segment only for these types. +$script:MermaidPostColonLabelType = @('sequence', 'class', 'state', 'er') + +<# + Arrow patterns are anchored regexes matched against a single candidate arrow + token produced by Get-MermaidArrowCandidate. They are deliberately more + permissive than the documented token list so that length variants (`---->`, + `====>`, `-...->`) and the tilde runs used by class-diagram generics never + produce a finding. The five deep entries below carry the full shape; the + keyword-accept entries are expanded from a compact map because they share one + fixed shape: keyword-checked only, brackets never structural, no arrow grammar. +#> +$script:MermaidDiagramType = [ordered]@{ + flowchart = [ordered]@{ + Keywords = @('flowchart', 'graph', 'flowchart-elk'); Verified = $true; Deep = $true; BracketStructural = $true + ArrowTokens = @('-->', '---', '-.->', '-.-', '==>', '===', '~~~', '--o', '--x', 'o--o', 'x--x', '<-->') + ArrowPattern = '^(?:~+|[<ox]?-{2,}[>ox]?|<?={2,}>?|-\.+-?>?|\.+-?>?)$' + } + sequence = [ordered]@{ + Keywords = @('sequenceDiagram'); Verified = $true; Deep = $true; BracketStructural = $false + ArrowTokens = @('->', '-->', '->>', '-->>', '<<->>', '<<-->>', '-x', '--x', '-)', '--)') + ArrowPattern = '^(?:~+|(?:<<)?-{1,2}(?:>{1,2}|x|\)|\\|/)?)$' + } + class = [ordered]@{ + Keywords = @('classDiagram', 'classDiagram-v2'); Verified = $true; Deep = $true; BracketStructural = $true + ArrowTokens = @('<|--', '--|>', '*--', '--*', 'o--', '--o', '-->', '<--', '--', '..>', '<..', '..|>', '<|..', '..') + ArrowPattern = '^(?:~+|(?:<\|?|\*|o)?(?:-{2,}|\.{2,})(?:\|>|>|\*|o)?)$' + } + state = [ordered]@{ + Keywords = @('stateDiagram-v2', 'stateDiagram'); Verified = $true; Deep = $true; BracketStructural = $true + ArrowTokens = @('-->') + ArrowPattern = '^(?:~+|-{2,}>)$' + } + er = [ordered]@{ + Keywords = @('erDiagram'); Verified = $true; Deep = $true; BracketStructural = $true + ArrowTokens = @('|o--o|', '||--||', '}o--o{', '}|--|{', '|o..o|', '}|..|{') + ArrowPattern = '^(?:~+|(?:\|o|\|\||\}o|\}\|)(?:-{2}|\.{2})(?:o\||\|\||o\{|\|\{))$' + } +} + +# Keyword-accept types, verified against the pinned documentation. The map value is +# the documented first-line keyword form or forms. `packet` is the 11.17 keyword; +# `packet-beta` was the earlier form, retained as an alias so neither spelling +# costs a false rejection. The three types whose documentation lists edge tokens +# carry them for reference only (see the reference-token block after the expansion +# loop); no arrow judgement is ever performed on a non-deep type. +$script:MermaidVerifiedKeywordOnlyType = [ordered]@{ + journey = @('journey') + gantt = @('gantt') + pie = @('pie') + quadrant = @('quadrantChart') + requirement = @('requirementDiagram') + gitgraph = @('gitGraph') + mindmap = @('mindmap') + timeline = @('timeline') + zenuml = @('zenuml') + sankey = @('sankey-beta') + xychart = @('xychart-beta') + block = @('block-beta') + packet = @('packet', 'packet-beta') + kanban = @('kanban') + architecture = @('architecture-beta') + radar = @('radar-beta') + treemap = @('treemap-beta') + c4 = @('C4Context', 'C4Container', 'C4Component', 'C4Dynamic', 'C4Deployment') + info = @('info') +} + +# 11.x documentation-sidebar additions. The diagram types are documented but their +# exact first-line keyword forms were not individually verified, so they are +# keyword-accept only and always carry a drift warning. +$script:MermaidUnverifiedKeywordOnlyType = [ordered]@{ + swimlanes = @('swimlanes') + eventmodeling = @('eventmodeling') + venn = @('venn') + ishikawa = @('ishikawa') + wardley = @('wardley') + cynefin = @('cynefin') + treeview = @('treeView') + railroad = @('railroad', 'railroad-beta') +} + +foreach ($tier in @( + @{ Map = $script:MermaidVerifiedKeywordOnlyType; Verified = $true }, + @{ Map = $script:MermaidUnverifiedKeywordOnlyType; Verified = $false })) { + foreach ($name in $tier.Map.Keys) { + $script:MermaidDiagramType[$name] = [ordered]@{ + Keywords = @($tier.Map[$name]); Verified = $tier.Verified; Deep = $false; BracketStructural = $false + ArrowTokens = @() + ArrowPattern = $null + } + } +} + +# Documented edge tokens for the keyword-accept types whose pages list them. These +# are reference data only: ArrowPattern stays $null, so no arrow is ever judged. +$script:MermaidDiagramType['requirement'].ArrowTokens = @('->', '<-') +$script:MermaidDiagramType['block'].ArrowTokens = @('-->', '--') +$script:MermaidDiagramType['architecture'].ArrowTokens = @('--', '-->') + +function Get-MermaidGrammarVersion { + <# + .SYNOPSIS + Returns the pinned Mermaid documentation version this table snapshots. + #> + [CmdletBinding()] + [OutputType([string])] + param() + return $script:MermaidGrammarVersion +} + +function Get-MermaidGrammarSourceUrl { + <# + .SYNOPSIS + Returns the documentation URL the pinned grammar table was read from. + #> + [CmdletBinding()] + [OutputType([string])] + param() + return $script:MermaidGrammarSourceUrl +} + +function Get-MermaidDiagramTypeName { + <# + .SYNOPSIS + Returns the canonical diagram-type names in table order. + #> + [CmdletBinding()] + [OutputType([string[]])] + param() + return [string[]]@($script:MermaidDiagramType.Keys) +} + +function Get-MermaidDiagramTypeEntry { + <# + .SYNOPSIS + Returns the grammar entry for a canonical diagram-type name, or $null. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param([Parameter(Mandatory)][AllowEmptyString()][string] $DiagramType) + + if (-not $script:MermaidDiagramType.Contains($DiagramType)) { return $null } + + return $script:MermaidDiagramType[$DiagramType] +} + +function Get-MermaidDeepCheckedType { + <# + .SYNOPSIS + Returns the diagram types that receive structural judgement beyond the + first-line keyword check. + #> + [CmdletBinding()] + [OutputType([string[]])] + param() + + $names = [System.Collections.Generic.List[string]]::new() + foreach ($name in $script:MermaidDiagramType.Keys) { + if ($script:MermaidDiagramType[$name].Deep) { + $names.Add($name) + } + } + + return [string[]]@($names.ToArray()) +} + +function Test-MermaidDeepCheckedType { + <# + .SYNOPSIS + Returns $true when the supplied canonical diagram type is deep-checked. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)][AllowEmptyString()][string] $DiagramType) + + $entry = Get-MermaidDiagramTypeEntry -DiagramType $DiagramType + if ($null -eq $entry) { return $false } + + return [bool]$entry.Deep +} + +function Test-MermaidBracketStructuralType { + <# + .SYNOPSIS + Returns $true when brackets are structural for the supplied diagram type. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)][AllowEmptyString()][string] $DiagramType) + + $entry = Get-MermaidDiagramTypeEntry -DiagramType $DiagramType + if ($null -eq $entry) { return $false } + + return [bool]$entry.BracketStructural +} + +function Test-MermaidPostColonLabelType { + <# + .SYNOPSIS + Returns $true when the diagram type puts free text after the first colon. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)][AllowEmptyString()][string] $DiagramType) + + return $script:MermaidPostColonLabelType -contains $DiagramType +} + +function Get-MermaidArrowToken { + <# + .SYNOPSIS + Returns the documented arrow/edge token set for a diagram type. + .DESCRIPTION + The documentation reference set. Validation uses Get-MermaidArrowPattern, + which additionally admits the documented length variants. + #> + [CmdletBinding()] + [OutputType([string[]])] + param([Parameter(Mandatory)][AllowEmptyString()][string] $DiagramType) + + $entry = Get-MermaidDiagramTypeEntry -DiagramType $DiagramType + if ($null -eq $entry) { return [string[]]@() } + + return [string[]]@($entry.ArrowTokens) +} + +function Get-MermaidArrowPattern { + <# + .SYNOPSIS + Returns the anchored regex accepting a valid arrow token for a type. + .DESCRIPTION + Returns $null when the type carries no arrow grammar, which the caller must + treat as "do not judge arrows" rather than "reject all arrows". + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)][AllowEmptyString()][string] $DiagramType) + + $entry = Get-MermaidDiagramTypeEntry -DiagramType $DiagramType + if ($null -eq $entry) { return $null } + + return $entry.ArrowPattern +} + +function Get-MermaidStatementKeyword { + <# + .SYNOPSIS + Returns the statement keywords exempt from arrow and bracket rules. + #> + [CmdletBinding()] + [OutputType([string[]])] + param() + return [string[]]@($script:MermaidStatementKeyword) +} + +function Test-MermaidStatementKeyword { + <# + .SYNOPSIS + Returns $true when a first token is an exempt statement keyword. + #> + [CmdletBinding()] + [OutputType([bool])] + param([AllowEmptyString()][string] $Token) + + if ([string]::IsNullOrEmpty($Token)) { return $false } + + return $script:MermaidStatementKeyword -contains $Token +} + +function Test-MermaidNonEdgeKeyword { + <# + .SYNOPSIS + Returns $true when a first token opens a block or declaration. + #> + [CmdletBinding()] + [OutputType([bool])] + param([AllowEmptyString()][string] $Token) + + if ([string]::IsNullOrEmpty($Token)) { return $false } + + return $script:MermaidNonEdgeKeyword -contains $Token +} + +function Test-MermaidPlausibleKeyword { + <# + .SYNOPSIS + Returns $true when a token is shaped like a Mermaid diagram keyword. + .DESCRIPTION + The shape rule is the version-drift safety valve: a token of letters, + digits, and hyphens beginning with a letter is treated as a plausible + keyword newer than the pinned allowlist. Only a missing or clearly + non-keyword first line is rejected outright. + #> + [CmdletBinding()] + [OutputType([bool])] + param([AllowEmptyString()][string] $Token) + + if ([string]::IsNullOrEmpty($Token)) { return $false } + + return [bool]($Token -cmatch '^[A-Za-z][A-Za-z0-9-]*$') +} + +function Test-MermaidSingleEditDistance { + <# + .SYNOPSIS + Returns $true when two tokens differ by exactly one character edit. + .DESCRIPTION + One edit means one substitution (equal lengths, exactly one differing + position) or one insertion or deletion (lengths differing by one, where + removing one character from the longer yields the shorter). Comparison is + ordinal and case-sensitive, matching Mermaid keyword resolution. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)][AllowEmptyString()][string] $First, + [Parameter(Mandatory)][AllowEmptyString()][string] $Second + ) + + if ([Math]::Abs($First.Length - $Second.Length) -gt 1) { return $false } + + if ($First.Length -eq $Second.Length) { + $mismatch = 0 + for ($index = 0; $index -lt $First.Length; $index++) { + if ($First[$index] -cne $Second[$index]) { + $mismatch++ + } + if ($mismatch -gt 1) { return $false } + } + return ($mismatch -eq 1) + } + + $longer = if ($First.Length -gt $Second.Length) { $First } else { $Second } + $shorter = if ($First.Length -gt $Second.Length) { $Second } else { $First } + for ($index = 0; $index -lt $longer.Length; $index++) { + if ([string]::Equals($longer.Remove($index, 1), $shorter, [System.StringComparison]::Ordinal)) { return $true } + } + + return $false +} + +function Resolve-MermaidMisspelledKeyword { + <# + .SYNOPSIS + Returns the known keyword a token appears to misspell, or $null. + .DESCRIPTION + Only tokens of five or more characters are considered, and only a + single-edit difference counts. The narrow radius is deliberate: a wider one + would begin catching genuinely new diagram types and convert the + version-drift warn-and-allow valve into a false rejection. A typo, by + contrast, is a defect the gate is required to name (`flowchar` for + `flowchart`). + #> + [CmdletBinding()] + [OutputType([string])] + param([AllowEmptyString()][string] $Token) + + if ([string]::IsNullOrEmpty($Token) -or $Token.Length -lt 5) { return $null } + + foreach ($name in $script:MermaidDiagramType.Keys) { + foreach ($keyword in $script:MermaidDiagramType[$name].Keywords) { + if ($keyword.Length -lt 5) { + continue + } + if (Test-MermaidSingleEditDistance -First $Token -Second $keyword) { return $keyword } + } + } + + return $null +} + +function Resolve-MermaidDiagramType { + <# + .SYNOPSIS + Resolves the declared diagram type from a candidate first line. + .DESCRIPTION + Takes the first whitespace-delimited token of the trimmed line, strips a + trailing colon or semicolon (the `gitGraph LR:` and `graph TD;` forms), and + looks it up with ordinal, case-sensitive comparison because Mermaid + keywords are case-sensitive (`C4Context`, `stateDiagram-v2`). + + Returns Token (the normalized first token), Type (canonical name or $null), + IsKnown, IsVerified, IsPlausibleKeyword, and MisspelledOf (the keyword an + unresolved token appears to misspell, or $null). + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param([AllowEmptyString()][string] $FirstLine) + + $token = '' + if (-not [string]::IsNullOrWhiteSpace($FirstLine)) { + $token = (($FirstLine.Trim() -split '\s+', 2)[0]) -replace '[:;]+$', '' + } + + $result = [ordered]@{ + Token = $token + Type = $null + IsKnown = $false + IsVerified = $false + IsPlausibleKeyword = (Test-MermaidPlausibleKeyword -Token $token) + MisspelledOf = $null + } + + if ([string]::IsNullOrEmpty($token)) { return $result } + + foreach ($name in $script:MermaidDiagramType.Keys) { + $entry = $script:MermaidDiagramType[$name] + foreach ($keyword in $entry.Keywords) { + if ([string]::Equals($keyword, $token, [System.StringComparison]::Ordinal)) { + $result.Type = $name + $result.IsKnown = $true + $result.IsVerified = [bool]$entry.Verified + return $result + } + } + } + + if ($result.IsPlausibleKeyword) { + $result.MisspelledOf = Resolve-MermaidMisspelledKeyword -Token $token + } + + return $result +} + +Export-ModuleMember -Function @( + 'Get-MermaidGrammarVersion', 'Get-MermaidGrammarSourceUrl', 'Get-MermaidDiagramTypeName', + 'Get-MermaidDiagramTypeEntry', 'Get-MermaidDeepCheckedType', 'Test-MermaidDeepCheckedType', + 'Test-MermaidBracketStructuralType', 'Test-MermaidPostColonLabelType', 'Get-MermaidArrowToken', + 'Get-MermaidArrowPattern', 'Get-MermaidStatementKeyword', 'Test-MermaidStatementKeyword', + 'Test-MermaidNonEdgeKeyword', 'Test-MermaidPlausibleKeyword', 'Test-MermaidSingleEditDistance', + 'Resolve-MermaidMisspelledKeyword', 'Resolve-MermaidDiagramType' +) diff --git a/.claude/lib/mermaid/MermaidLineScanner.psm1 b/.claude/lib/mermaid/MermaidLineScanner.psm1 new file mode 100644 index 000000000..5aab2f3a6 --- /dev/null +++ b/.claude/lib/mermaid/MermaidLineScanner.psm1 @@ -0,0 +1,488 @@ +<# +.SYNOPSIS + Quote-aware single-line scanner for the structural Mermaid validator. + +.DESCRIPTION + Pure string analysis for one line of Mermaid source. Every rule here exists to + avoid a specific false positive catalogued in the feature research: + + - Characters inside a double-quoted span never participate in bracket + balance, arrow scanning, or comment detection, so `A["foo[bar](baz)"]` + and `A["50%% off"]` are accepted. + - A backslash is an ordinary character. Mermaid has no escape system; the + documented mechanism is the `#quot;` entity. Treating `\"` as an escape + would turn a valid label into a spurious unterminated-quote finding. + - Angle brackets are never structural, so `<br/>` in a label is inert and + `>` participates only as part of an arrow token. + - A `%%{...}%%` directive is recognized before comment stripping, so a + directive is never deleted as a comment. + - Arrow tokens are masked out before bracket counting, because `{`, `}`, and + `|` appear inside ER cardinality tokens (`||--o{`) and class relations + (`<|--`) where they are not brackets at all. + - Bracket-label contents are masked before arrow scanning, so arrow-like + text inside a node label is not read as an edge token. + - A single-character arrow core is kept only when an affix was applied, so + the tilde delimiters of a class generic (`List~int~`) are not arrow + tokens while the one-dash sequence arrows (`->`, `-x`, `-)`) still are. + - The letter-shaped affixes `o` and `x` are absorbed only across a + non-word boundary, so the `x` in `Box--Bar` never extends the `--` core. + + 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. +#> + +Set-StrictMode -Version Latest + +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'MermaidGrammar.psm1') -Force + +# 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 +# two-character forms are tried before the one-character forms so `<<-->>` and +# `||--o{` tokenize whole. +$script:ArrowLeftAffixTwo = @('<<', '<|', '}o', '}|', '|o', '||') +$script:ArrowLeftAffixOne = @('<', 'o', 'x', '*') +$script:ArrowRightAffixTwo = @('>>', '|>', 'o|', 'o{', '||', '|{') +$script:ArrowRightAffixOne = @('>', 'o', 'x', ')', '*', '\', '/') + +function Test-MermaidWordCharacter { + <# + .SYNOPSIS + Returns $true when the character is a letter, digit, or underscore. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [char] $Character + ) + + if ($Character -eq '_') { + return $true + } + return [char]::IsLetterOrDigit($Character) +} + +function Test-MermaidDirectiveLine { + <# + .SYNOPSIS + Returns $true when the line is a `%%{...}%%` init directive. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowEmptyString()] + [string] $Line + ) + + if ([string]::IsNullOrWhiteSpace($Line)) { + return $false + } + return $Line.TrimStart().StartsWith('%%{', [System.StringComparison]::Ordinal) +} + +function Test-MermaidCommentLine { + <# + .SYNOPSIS + Returns $true when the whole line is a `%%` comment and not a directive. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowEmptyString()] + [string] $Line + ) + + if ([string]::IsNullOrWhiteSpace($Line)) { + return $false + } + if (Test-MermaidDirectiveLine -Line $Line) { + return $false + } + return $Line.TrimStart().StartsWith('%%', [System.StringComparison]::Ordinal) +} + +function Get-MermaidQuoteMaskedText { + <# + .SYNOPSIS + Masks quoted spans and strips a trailing `%%` comment from one line. + .DESCRIPTION + Returns Text (every character of every double-quoted span, including the + quote marks, replaced by a space, truncated at the first `%%` outside a + quoted span; indices align one-for-one with the input up to that point), + HasUnterminatedQuote, and ColonIndex (index of the first `:` outside a + quoted span, or -1). + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [AllowEmptyString()] + [string] $Line + ) + + $builder = [System.Text.StringBuilder]::new() + $inQuote = $false + $colonIndex = -1 + $index = 0 + + while ($index -lt $Line.Length) { + $character = $Line[$index] + + if ($inQuote) { + if ($character -eq '"') { + $inQuote = $false + } + [void]$builder.Append(' ') + $index++ + continue + } + + if ($character -eq '"') { + $inQuote = $true + [void]$builder.Append(' ') + $index++ + continue + } + + # A comment runs to end of line. The directive case is recognized by the + # caller before this function is reached, so it is never stripped here. + if ($character -eq '%' -and ($index + 1) -lt $Line.Length -and $Line[$index + 1] -eq '%') { + break + } + + if ($character -eq ':' -and $colonIndex -lt 0) { + $colonIndex = $index + } + + [void]$builder.Append($character) + $index++ + } + + return [ordered]@{ + Text = $builder.ToString() + HasUnterminatedQuote = $inQuote + ColonIndex = $colonIndex + } +} + +function Get-MermaidArrowCandidate { + <# + .SYNOPSIS + Extracts the candidate arrow tokens from already-masked line text. + .PARAMETER Text + Masked line text, normally the ArrowText of Get-MermaidLineScan. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [AllowEmptyString()] + [string] $Text + ) + + $candidates = [System.Collections.Generic.List[string]]::new() + if ([string]::IsNullOrEmpty($Text)) { + return [string[]]@() + } + + foreach ($match in [regex]::Matches($Text, '[-=.~]+')) { + $start = $match.Index + $length = $match.Length + + $leftLength = 0 + if ($start -ge 2 -and $script:ArrowLeftAffixTwo -contains $Text.Substring($start - 2, 2)) { + $leftLength = 2 + } elseif ($start -ge 1) { + $affix = [string]$Text[$start - 1] + if ($script:ArrowLeftAffixOne -contains $affix) { + if ($affix -eq 'o' -or $affix -eq 'x') { + if ($start -lt 2 -or -not (Test-MermaidWordCharacter -Character $Text[$start - 2])) { + $leftLength = 1 + } + } else { + $leftLength = 1 + } + } + } + + $end = $start + $length + $rightLength = 0 + if (($end + 2) -le $Text.Length -and $script:ArrowRightAffixTwo -contains $Text.Substring($end, 2)) { + $rightLength = 2 + } elseif ($end -lt $Text.Length) { + $affix = [string]$Text[$end] + if ($script:ArrowRightAffixOne -contains $affix) { + if ($affix -eq 'o' -or $affix -eq 'x') { + if (($end + 1) -ge $Text.Length -or -not (Test-MermaidWordCharacter -Character $Text[$end + 1])) { + $rightLength = 1 + } + } else { + $rightLength = 1 + } + } + } + + if ($length -lt 2 -and $leftLength -eq 0 -and $rightLength -eq 0) { + continue + } + + $candidates.Add($Text.Substring($start - $leftLength, $leftLength + $length + $rightLength)) + } + + return [string[]]@($candidates.ToArray()) +} + +function Get-MermaidLabelMaskedText { + <# + .SYNOPSIS + Masks the contents of bracket-delimited label spans, keeping the brackets. + .DESCRIPTION + Indices align one-for-one with the input. Closers are clamped at depth + zero so a sequence async arrow (`-)`) is preserved. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [AllowEmptyString()] + [string] $Text + ) + + $builder = [System.Text.StringBuilder]::new() + $depth = 0 + + foreach ($character in $Text.ToCharArray()) { + if ($character -eq '[' -or $character -eq '(' -or $character -eq '{') { + $depth++ + [void]$builder.Append($character) + } elseif ($character -eq ']' -or $character -eq ')' -or $character -eq '}') { + if ($depth -gt 0) { + $depth-- + } + [void]$builder.Append($character) + } elseif ($depth -gt 0) { + [void]$builder.Append(' ') + } else { + [void]$builder.Append($character) + } + } + + return $builder.ToString() +} + +function Get-MermaidArrowMaskedText { + <# + .SYNOPSIS + Blanks every arrow candidate so bracket counting ignores arrow tokens. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [AllowEmptyString()] + [string] $Text + ) + + if ([string]::IsNullOrEmpty($Text)) { + return $Text + } + + $characters = $Text.ToCharArray() + $offset = 0 + foreach ($candidate in @(Get-MermaidArrowCandidate -Text $Text)) { + $position = $Text.IndexOf($candidate, $offset, [System.StringComparison]::Ordinal) + if ($position -lt 0) { + continue + } + for ($index = $position; $index -lt ($position + $candidate.Length); $index++) { + $characters[$index] = ' ' + } + $offset = $position + $candidate.Length + } + + return [string]::new($characters) +} + +function Get-MermaidBracketDelta { + <# + .SYNOPSIS + Counts the net square, round, and curly bracket delta of masked text. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [AllowEmptyString()] + [string] $Text + ) + + $square = 0 + $round = 0 + $curly = 0 + + foreach ($character in $Text.ToCharArray()) { + switch ($character) { + '[' { $square++ } + ']' { $square-- } + '(' { $round++ } + ')' { $round-- } + '{' { $curly++ } + '}' { $curly-- } + default { } + } + } + + return [ordered]@{ Square = $square; Round = $round; Curly = $curly } +} + +function Get-MermaidLineScan { + <# + .SYNOPSIS + Produces the full scan result for one line of Mermaid source. + .DESCRIPTION + Returns Raw, IsBlank, IsDirective, IsComment, Structural (quote-masked and + comment-stripped), ArrowText (Structural with bracket-label contents + masked), BracketText (Structural with arrow tokens masked), + HasUnterminatedQuote, ColonIndex, FirstToken, Class (Blank | Directive | + Comment | StatementKeyword | Edge | Unclassifiable), and BracketDelta. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [AllowEmptyString()] + [string] $Line + ) + + $isDirective = Test-MermaidDirectiveLine -Line $Line + $isComment = Test-MermaidCommentLine -Line $Line + $isBlank = [string]::IsNullOrWhiteSpace($Line) + + if ($isDirective -or $isComment -or $isBlank) { + $inertClass = if ($isBlank) { 'Blank' } elseif ($isDirective) { 'Directive' } else { 'Comment' } + return [ordered]@{ + Raw = $Line + IsBlank = $isBlank + IsDirective = $isDirective + IsComment = $isComment + Structural = '' + ArrowText = '' + BracketText = '' + HasUnterminatedQuote = $false + ColonIndex = -1 + FirstToken = '' + Class = $inertClass + BracketDelta = [ordered]@{ Square = 0; Round = 0; Curly = 0 } + } + } + + $masked = Get-MermaidQuoteMaskedText -Line $Line + $structural = $masked.Text + $arrowText = Get-MermaidLabelMaskedText -Text $structural + $bracketText = Get-MermaidArrowMaskedText -Text $structural + + # A trailing colon or semicolon is stripped from the first token because the + # accessibility statements are written `accTitle: text` and `accDescr: text`. + # Without this normalization those lines would miss the statement-keyword + # exemption and their free text would be arrow-checked. + $firstToken = '' + if (-not [string]::IsNullOrWhiteSpace($structural)) { + $firstToken = ($structural.Trim() -split '\s+', 2)[0] -replace '[:;]+$', '' + } + + $class = 'Unclassifiable' + if (Test-MermaidStatementKeyword -Token $firstToken) { + $class = 'StatementKeyword' + } elseif (@(Get-MermaidArrowCandidate -Text $arrowText).Count -gt 0) { + $class = 'Edge' + } + + return [ordered]@{ + Raw = $Line + IsBlank = $false + IsDirective = $false + IsComment = $false + Structural = $structural + ArrowText = $arrowText + BracketText = $bracketText + HasUnterminatedQuote = $masked.HasUnterminatedQuote + ColonIndex = $masked.ColonIndex + FirstToken = $firstToken + Class = $class + BracketDelta = (Get-MermaidBracketDelta -Text $bracketText) + } +} + +function Get-MermaidLineClass { + <# + .SYNOPSIS + Returns the classification of one line of Mermaid source. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [AllowEmptyString()] + [string] $Line + ) + + return (Get-MermaidLineScan -Line $Line).Class +} + +function Test-MermaidLineBracketBalanced { + <# + .SYNOPSIS + Returns $true when a line's own bracket deltas are all zero. + .DESCRIPTION + Per-line balance is a diagnostic, not the validator's verdict. Legal + Mermaid opens a brace block on one line and closes it on another, so the + validator aggregates deltas across the diagram body instead. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowEmptyString()] + [string] $Line + ) + + $delta = (Get-MermaidLineScan -Line $Line).BracketDelta + return ($delta.Square -eq 0 -and $delta.Round -eq 0 -and $delta.Curly -eq 0) +} + +function Split-MermaidStatementLabel { + <# + .SYNOPSIS + Splits a line at its first unquoted colon into statement and label parts. + .DESCRIPTION + Mermaid puts free text after the first colon on sequence, class, state, and + ER statement lines, so arrow and quote judgement must stop at the colon. + Returns Statement, Label, and HasLabel. With no unquoted colon, Statement + is the whole line and Label is empty. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [AllowEmptyString()] + [string] $Line + ) + + $scan = Get-MermaidLineScan -Line $Line + if ($scan.ColonIndex -lt 0) { + return [ordered]@{ Statement = $Line; Label = ''; HasLabel = $false } + } + + $index = [Math]::Min($scan.ColonIndex, $Line.Length) + return [ordered]@{ + Statement = $Line.Substring(0, $index) + Label = $Line.Substring([Math]::Min($index + 1, $Line.Length)) + HasLabel = $true + } +} + +Export-ModuleMember -Function ` + Test-MermaidWordCharacter, ` + Test-MermaidDirectiveLine, ` + Test-MermaidCommentLine, ` + Get-MermaidQuoteMaskedText, ` + Get-MermaidArrowCandidate, ` + Get-MermaidLabelMaskedText, ` + Get-MermaidArrowMaskedText, ` + Get-MermaidBracketDelta, ` + Get-MermaidLineScan, ` + Get-MermaidLineClass, ` + Test-MermaidLineBracketBalanced, ` + Split-MermaidStatementLabel diff --git a/.claude/lib/mermaid/MermaidMarkdownFences.psm1 b/.claude/lib/mermaid/MermaidMarkdownFences.psm1 new file mode 100644 index 000000000..9ffb41e79 --- /dev/null +++ b/.claude/lib/mermaid/MermaidMarkdownFences.psm1 @@ -0,0 +1,298 @@ +<# +.SYNOPSIS + Markdown fenced-block tracker and opt-out marker detector for the Mermaid gate. + +.DESCRIPTION + Extracts ```` ```mermaid ```` blocks from Markdown text without parsing + CommonMark. The rule set is deliberately small and each rule exists for a + stated reason: + + - An opening fence is a run of three or more backticks or three or more + tildes, indented up to three spaces, optionally prefixed by blockquote + markers, whose info string's first word is `mermaid` (case-insensitive). + - A closing fence uses the same fence character, is at least as long as the + opening run, carries no info string, and sits at the same blockquote + depth. + - A fence stack is maintained so a `mermaid` fence nested inside an outer + open fence is reported as nested. Documentation showing example Mermaid is + not a diagram, so the validator must skip it (fail-open item 6). + - An unclosed fence is tolerated: its collected body is still reported, so a + diagram at the end of a truncated document is not silently dropped. + - Body lines of a fence opened inside a blockquote are stripped of their + `> ` prefix, so a blockquoted diagram validates as its unquoted twin. + - Tilde fences may contain backtick runs and vice versa, which the + same-character close rule handles without special cases. + + The opt-out marker is `<!-- mermaid-validator: ignore -->` on the line + immediately preceding the opening fence, with no intervening line. Its scope + is exactly that one block; a later block needs its own marker. The marker + exists so that documentation deliberately quoting invalid Mermaid is never + blocked, which would be worse than having no gate at all. + + 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. +#> + +Set-StrictMode -Version Latest + +# The marker text must be exactly `mermaid-validator: ignore` and is +# case-sensitive. Whitespace is permitted around the line, around the comment +# delimiters, and a blockquote prefix is tolerated so the marker works inside a +# quoted passage. +$script:OptOutMarkerPattern = '^(?:\s{0,3}>\s?)*\s*<!--\s*mermaid-validator: ignore\s*-->\s*$' + +# Fence line shape: optional blockquote prefixes, up to three spaces of +# indentation, then the fence run, then the info string. +$script:FenceLinePattern = '^(?<quote>(?:\s{0,3}>\s?)*)(?<indent>\s{0,3})(?<fence>`{3,}|~{3,})(?<info>.*)$' + +function Split-MermaidTextLine { + <# + .SYNOPSIS + Splits text into lines, normalizing CRLF, CR, and LF endings. + .DESCRIPTION + Line-ending normalization happens once, here, so every downstream rule + sees the same line array and a CRLF document produces a byte-identical + verdict to its LF twin. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [AllowEmptyString()] + [AllowNull()] + [string] $Text + ) + + if ([string]::IsNullOrEmpty($Text)) { + return [string[]]@() + } + + return [string[]]($Text -split '\r\n|\n|\r') +} + +function Test-MermaidOptOutMarker { + <# + .SYNOPSIS + Returns $true when a line is the documented Mermaid validator opt-out marker. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowEmptyString()] + [AllowNull()] + [string] $Line + ) + + if ([string]::IsNullOrWhiteSpace($Line)) { + return $false + } + + return [bool]($Line -cmatch $script:OptOutMarkerPattern) +} + +function Get-MermaidFenceLine { + <# + .SYNOPSIS + Parses a candidate Markdown fence line. + .DESCRIPTION + Returns $null when the line is not a fence line. Otherwise returns an + ordered dictionary with FenceCharacter, FenceLength, QuoteDepth, Info, and + IsMermaid (the info string's first word is `mermaid`, case-insensitive). + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [AllowEmptyString()] + [AllowNull()] + [string] $Line + ) + + if ([string]::IsNullOrEmpty($Line)) { + return $null + } + + $match = [regex]::Match($Line, $script:FenceLinePattern) + if (-not $match.Success) { + return $null + } + + $fence = $match.Groups['fence'].Value + $info = $match.Groups['info'].Value.Trim() + $quoteDepth = ([regex]::Matches($match.Groups['quote'].Value, '>')).Count + + $firstWord = '' + if (-not [string]::IsNullOrWhiteSpace($info)) { + $firstWord = ($info -split '[\s{,]+', 2)[0] + } + + return [ordered]@{ + FenceCharacter = [string]$fence[0] + FenceLength = $fence.Length + QuoteDepth = $quoteDepth + Info = $info + IsMermaid = [bool]($firstWord -imatch '^mermaid$') + } +} + +function Get-MermaidUnquotedLine { + <# + .SYNOPSIS + Strips up to QuoteDepth blockquote markers from the start of a body line. + .DESCRIPTION + A fence opened inside a blockquote carries a `> ` prefix on every body + line. Leaving the prefix in place would make the first body line start + with `>`, which the validator would read as a non-keyword first line and + reject. Stripping it is what makes a blockquoted diagram validate the same + as its unquoted twin. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [AllowEmptyString()] + [AllowNull()] + [string] $Line, + + [Parameter(Mandatory)] + [int] $QuoteDepth + ) + + if ($QuoteDepth -le 0 -or [string]::IsNullOrEmpty($Line)) { + return $Line + } + + $result = $Line + for ($count = 0; $count -lt $QuoteDepth; $count++) { + $stripped = $result -replace '^\s{0,3}>\s?', '' + if ($stripped -eq $result) { + break + } + $result = $stripped + } + + return $result +} + +function Test-MermaidFenceClose { + <# + .SYNOPSIS + Returns $true when a parsed fence line closes the supplied open fence. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [System.Collections.Specialized.OrderedDictionary] $Fence, + + [Parameter(Mandatory)] + [System.Collections.Specialized.OrderedDictionary] $OpenFence + ) + + if ($Fence.FenceCharacter -ne $OpenFence.FenceCharacter) { + return $false + } + if ($Fence.FenceLength -lt $OpenFence.FenceLength) { + return $false + } + if ($Fence.QuoteDepth -ne $OpenFence.QuoteDepth) { + return $false + } + + return [string]::IsNullOrEmpty($Fence.Info) +} + +function Get-MermaidFenceBlock { + <# + .SYNOPSIS + Extracts every Mermaid fenced block from Markdown text. + .DESCRIPTION + Returns an array of ordered dictionaries, one per ```` ```mermaid ```` + block, each with: + Content the block body, joined with newlines + StartLine 1-based line number of the opening fence + BodyStartLine 1-based line number of the first body line + IsNested the fence opened while another fence was already open + IsOptedOut the immediately preceding line carried the opt-out marker + IsClosed a matching closing fence was found + A document with no Mermaid fence returns an empty array. + .PARAMETER Content + The full Markdown text. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [AllowEmptyString()] + [AllowNull()] + [string] $Content + ) + + $blocks = [System.Collections.Generic.List[object]]::new() + $lines = @(Split-MermaidTextLine -Text $Content) + if ($lines.Count -eq 0) { + return [object[]]@() + } + + $stack = [System.Collections.Generic.List[object]]::new() + + for ($index = 0; $index -lt $lines.Count; $index++) { + $line = $lines[$index] + $fence = Get-MermaidFenceLine -Line $line + + if ($null -ne $fence) { + if ($stack.Count -gt 0 -and (Test-MermaidFenceClose -Fence $fence -OpenFence $stack[$stack.Count - 1].Fence)) { + $entry = $stack[$stack.Count - 1] + $stack.RemoveAt($stack.Count - 1) + if ($entry.Fence.IsMermaid) { + $entry.Block.Content = ($entry.Body -join "`n") + $entry.Block.IsClosed = $true + $blocks.Add($entry.Block) + } + continue + } + + $marker = $false + if ($index -gt 0) { + $marker = Test-MermaidOptOutMarker -Line $lines[$index - 1] + } + + $stack.Add([ordered]@{ + Fence = $fence + Body = [System.Collections.Generic.List[string]]::new() + Block = [ordered]@{ + Content = '' + StartLine = $index + 1 + BodyStartLine = $index + 2 + IsNested = ($stack.Count -gt 0) + IsOptedOut = $marker + IsClosed = $false + } + }) + continue + } + + if ($stack.Count -gt 0) { + $top = $stack[$stack.Count - 1] + $stack[$stack.Count - 1].Body.Add((Get-MermaidUnquotedLine -Line $line -QuoteDepth $top.Fence.QuoteDepth)) + } + } + + # Unclosed-fence tolerance: report whatever body was collected so a diagram at + # the end of a truncated document is not silently dropped. + foreach ($entry in $stack) { + if ($entry.Fence.IsMermaid) { + $entry.Block.Content = ($entry.Body -join "`n") + $blocks.Add($entry.Block) + } + } + + # Unclosed blocks are appended after the closed ones, so sort back into + # document order to keep the caller's line-number reporting monotonic. + return [object[]]@($blocks.ToArray() | Sort-Object -Property { $_.StartLine }) +} + +Export-ModuleMember -Function ` + Split-MermaidTextLine, ` + Test-MermaidOptOutMarker, ` + Get-MermaidFenceLine, ` + Get-MermaidUnquotedLine, ` + Test-MermaidFenceClose, ` + Get-MermaidFenceBlock diff --git a/.claude/lib/mermaid/MermaidValidation.psm1 b/.claude/lib/mermaid/MermaidValidation.psm1 new file mode 100644 index 000000000..d266ebe3c --- /dev/null +++ b/.claude/lib/mermaid/MermaidValidation.psm1 @@ -0,0 +1,496 @@ +<# +.SYNOPSIS + Structural Mermaid diagram validator (issue #491). + +.DESCRIPTION + Public entry point `Test-MermaidDiagram -Content <string>` returns a structured + result so a future CI-side deep check can be layered without changing the hook + contract: + + Verdict Valid | Invalid | NotJudged + DiagramType the resolved canonical type, the declared token when the token + did not resolve, or $null + Findings array of { Class; Line; Message } for each detected defect + Warnings array of strings, for example the keyword-drift warning + + The gate's contract is "rejects the named defect classes", NOT "proves + validity". `Valid` means no defect of a checked class was found. The checked + classes are: missing or clearly non-keyword first line, misspelled diagram + keyword, malformed YAML frontmatter, empty or whitespace-only body, + unbalanced `[]` / `()` / `{}`, unterminated double-quoted string, arrow token + invalid for the declared type, and `subgraph`/`end` imbalance. Semantic and + deep-grammar errors are outside its reach; see the feature research for the + full "cannot catch" list. + + Fail-open policy, all of which allow rather than reject: + 1. A first-line token outside the allowlist but shaped like a plausible + keyword warns and allows. This is the Mermaid version-drift safety valve. + Only a missing or clearly non-keyword first line blocks. + 2. Diagram types outside the deep-checked set are keyword-checked only. + 3. A line the classifier cannot categorize is skipped, never rejected. + 4. A statement-keyword line is exempt from arrow and bracket judgement. + 5. A block-opening keyword line is exempt from arrow judgement. + 6. An unverified keyword-accept row warns and declines to judge the body. + 7. ZenUML bodies use an external plugin grammar and are keyword-checked only. + + 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. CRLF, CR, and LF inputs produce identical verdicts because line + splitting is normalized once in MermaidMarkdownFences.psm1. +#> + +Set-StrictMode -Version Latest + +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 + +function Get-MermaidFinding { + <# + .SYNOPSIS + Builds one finding record for the structured validation result. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] + [string] $Class, + + [Parameter(Mandatory)] + [int] $Line, + + [Parameter(Mandatory)] + [string] $Message + ) + + return [ordered]@{ Class = $Class; Line = $Line; Message = $Message } +} + +function Get-MermaidFrontmatter { + <# + .SYNOPSIS + Extracts a leading YAML frontmatter block from a diagram's lines. + .DESCRIPTION + Returns HasFrontmatter, IsMalformed (an opening `---` with no closing + `---`), Keys (the top-level `key:` names found), and BodyStartIndex (the + zero-based index of the first line after the frontmatter). + + Frontmatter is only recognized when the first non-blank line is exactly + `---`, which is the documented Mermaid form. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]] $Line + ) + + $result = [ordered]@{ + HasFrontmatter = $false + IsMalformed = $false + Keys = [string[]]@() + BodyStartIndex = 0 + } + + $first = 0 + while ($first -lt $Line.Count -and [string]::IsNullOrWhiteSpace($Line[$first])) { + $first++ + } + if ($first -ge $Line.Count -or $Line[$first].Trim() -ne '---') { + return $result + } + + $result.HasFrontmatter = $true + $keys = [System.Collections.Generic.List[string]]::new() + for ($index = $first + 1; $index -lt $Line.Count; $index++) { + if ($Line[$index].Trim() -eq '---') { + $result.Keys = [string[]]@($keys.ToArray()) + $result.BodyStartIndex = $index + 1 + return $result + } + $match = [regex]::Match($Line[$index], '^(?<key>[A-Za-z_][A-Za-z0-9_-]*)\s*:') + if ($match.Success) { + $keys.Add($match.Groups['key'].Value) + } + } + + $result.IsMalformed = $true + $result.BodyStartIndex = $Line.Count + return $result +} + +function Test-MermaidManagedDiagram { + <# + .SYNOPSIS + Returns $true when a diagram's frontmatter carries the Mermaid Chart `id:` marker. + .DESCRIPTION + `id:` in the frontmatter is what the Mermaid Chart extension writes when a + diagram is connected to the cloud sync workflow. A diagram carrying it must + not be hand-edited, so the hook uses this detector as its managed-diagram + guard. An `id:` key with an empty value is not treated as a marker, because + an unconnected placeholder should not lock the file. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowEmptyString()] + [AllowNull()] + [string] $Content + ) + + if ([string]::IsNullOrWhiteSpace($Content)) { + return $false + } + + $lines = @(Split-MermaidTextLine -Text $Content) + $frontmatter = Get-MermaidFrontmatter -Line $lines + if (-not $frontmatter.HasFrontmatter) { + return $false + } + if ($frontmatter.Keys -notcontains 'id') { + return $false + } + + $limit = if ($frontmatter.IsMalformed) { $lines.Count } else { $frontmatter.BodyStartIndex - 1 } + for ($index = 0; $index -lt $limit; $index++) { + $match = [regex]::Match($lines[$index], '^\s*id\s*:\s*(?<value>.*)$') + if ($match.Success -and -not [string]::IsNullOrWhiteSpace($match.Groups['value'].Value)) { + return $true + } + } + + return $false +} + +function Get-MermaidKeywordLineIndex { + <# + .SYNOPSIS + Finds the index of the diagram keyword line within a body line range. + .DESCRIPTION + Skips blank lines, `%%` comments, and `%%{...}%%` directives, which may all + precede the keyword. Returns -1 when no candidate line exists. + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]] $Line, + + [Parameter(Mandatory)] + [int] $StartIndex + ) + + for ($index = $StartIndex; $index -lt $Line.Count; $index++) { + $scan = Get-MermaidLineScan -Line $Line[$index] + if ($scan.IsBlank -or $scan.IsComment -or $scan.IsDirective) { + continue + } + return $index + } + + return -1 +} + +function Get-MermaidArrowFinding { + <# + .SYNOPSIS + Returns arrow findings for one scanned line of a deep-checked diagram. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] + [System.Collections.Specialized.OrderedDictionary] $Scan, + + [Parameter(Mandatory)] + [string] $DiagramType, + + [Parameter(Mandatory)] + [int] $LineNumber + ) + + $findings = [System.Collections.Generic.List[object]]::new() + $pattern = Get-MermaidArrowPattern -DiagramType $DiagramType + if ([string]::IsNullOrEmpty($pattern)) { + return [object[]]@() + } + + # Statement-keyword and block-opening lines carry URLs, CSS, and free text. + if ($Scan.Class -eq 'StatementKeyword' -or (Test-MermaidNonEdgeKeyword -Token $Scan.FirstToken)) { + return [object[]]@() + } + + # For the types whose statements put free text after the first colon, arrow + # judgement stops at the colon. + $text = $Scan.ArrowText + if ((Test-MermaidPostColonLabelType -DiagramType $DiagramType) -and $Scan.ColonIndex -ge 0) { + $cut = [Math]::Min($Scan.ColonIndex, $text.Length) + $text = $text.Substring(0, $cut) + } + + foreach ($token in @(Get-MermaidArrowCandidate -Text $text)) { + if ($token -match $pattern) { + continue + } + $findings.Add((Get-MermaidFinding -Class 'InvalidArrowToken' -Line $LineNumber -Message "the token '$token' is not a valid edge form for a '$DiagramType' diagram")) + } + + return [object[]]@($findings.ToArray()) +} + +function Get-MermaidBodyFinding { + <# + .SYNOPSIS + Runs the deep structural checks over a deep-checked diagram's body. + .DESCRIPTION + Bracket balance is aggregated across the body rather than judged per line, + because legal Mermaid opens a brace block on one line and closes it on + another. Closers are clamped at zero and a closer without an opener is never + a finding, because a statement-keyword line such as `class Animal {` is + exempt from bracket counting while its closing `}` is not, and reporting + that as a defect would reject valid class diagrams. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]] $Line, + + [Parameter(Mandatory)] + [int] $BodyStartIndex, + + [Parameter(Mandatory)] + [string] $DiagramType, + + [Parameter(Mandatory)] + [int] $LineOffset + ) + + $findings = [System.Collections.Generic.List[object]]::new() + $bracketStructural = Test-MermaidBracketStructuralType -DiagramType $DiagramType + $postColon = Test-MermaidPostColonLabelType -DiagramType $DiagramType + $openers = [ordered]@{ + Square = [System.Collections.Generic.List[int]]::new() + Round = [System.Collections.Generic.List[int]]::new() + Curly = [System.Collections.Generic.List[int]]::new() + } + $subgraphOpen = [System.Collections.Generic.List[int]]::new() + + for ($index = $BodyStartIndex; $index -lt $Line.Count; $index++) { + $lineNumber = $index + 1 + $LineOffset + $scan = Get-MermaidLineScan -Line $Line[$index] + if ($scan.IsBlank -or $scan.IsComment -or $scan.IsDirective) { + continue + } + + foreach ($finding in @(Get-MermaidArrowFinding -Scan $scan -DiagramType $DiagramType -LineNumber $lineNumber)) { + $findings.Add($finding) + } + + if ($scan.Class -ne 'StatementKeyword') { + $quoteText = $scan.Raw + if ($postColon -and $scan.ColonIndex -ge 0) { + $quoteText = $scan.Raw.Substring(0, [Math]::Min($scan.ColonIndex, $scan.Raw.Length)) + } + if ((Get-MermaidLineScan -Line $quoteText).HasUnterminatedQuote) { + $findings.Add((Get-MermaidFinding -Class 'UnterminatedQuote' -Line $lineNumber -Message 'a double-quoted label is not closed on this line')) + } + } + + if ($DiagramType -eq 'flowchart') { + if ($scan.FirstToken -eq 'subgraph') { + $subgraphOpen.Add($lineNumber) + } elseif ($scan.FirstToken -eq 'end' -and $subgraphOpen.Count -gt 0) { + $subgraphOpen.RemoveAt($subgraphOpen.Count - 1) + } + } + + if (-not $bracketStructural -or $scan.Class -eq 'StatementKeyword') { + continue + } + + foreach ($kind in @('Square', 'Round', 'Curly')) { + $delta = $scan.BracketDelta[$kind] + for ($count = 0; $count -lt $delta; $count++) { + $openers[$kind].Add($lineNumber) + } + for ($count = 0; $count -gt $delta; $count--) { + if ($openers[$kind].Count -gt 0) { + $openers[$kind].RemoveAt($openers[$kind].Count - 1) + } + } + } + } + + $bracketName = [ordered]@{ Square = '[]'; Round = '()'; Curly = '{}' } + foreach ($kind in @('Square', 'Round', 'Curly')) { + if ($openers[$kind].Count -gt 0) { + $findings.Add((Get-MermaidFinding -Class 'UnbalancedBracket' -Line $openers[$kind][0] -Message "a '$($bracketName[$kind])' bracket opened here is never closed")) + } + } + + foreach ($lineNumber in $subgraphOpen) { + $findings.Add((Get-MermaidFinding -Class 'UnclosedSubgraph' -Line $lineNumber -Message "the 'subgraph' opened here has no matching 'end'")) + } + + return [object[]]@($findings.ToArray()) +} + +function Get-MermaidResult { + <# + .SYNOPSIS + Builds the structured validation result from its parts. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] + [string] $Verdict, + + [AllowNull()] + [AllowEmptyString()] + [string] $DiagramType, + + [AllowEmptyCollection()] + [object[]] $Findings = @(), + + [AllowEmptyCollection()] + [string[]] $Warnings = @() + ) + + return [ordered]@{ + Verdict = $Verdict + DiagramType = $DiagramType + Findings = [object[]]@($Findings) + Warnings = [string[]]@($Warnings) + } +} + +function Test-MermaidDiagram { + <# + .SYNOPSIS + Validates one Mermaid diagram and returns the structured result. + .PARAMETER Content + The full diagram text: optional YAML frontmatter, optional directives and + comments, the diagram keyword line, and the body. + .PARAMETER LineOffset + Added to every reported line number. Callers validating a fenced block + inside a larger document pass the block's body start line minus one so the + reported numbers are file-relative. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [AllowEmptyString()] + [AllowNull()] + [string] $Content, + + [int] $LineOffset = 0 + ) + + if ([string]::IsNullOrWhiteSpace($Content)) { + return Get-MermaidResult -Verdict 'Invalid' -DiagramType $null -Findings @( + (Get-MermaidFinding -Class 'EmptyDiagram' -Line (1 + $LineOffset) -Message 'the diagram is empty or contains only whitespace') + ) + } + + $lines = @(Split-MermaidTextLine -Text $Content) + $frontmatter = Get-MermaidFrontmatter -Line $lines + if ($frontmatter.IsMalformed) { + return Get-MermaidResult -Verdict 'Invalid' -DiagramType $null -Findings @( + (Get-MermaidFinding -Class 'MalformedFrontmatter' -Line (1 + $LineOffset) -Message 'the YAML frontmatter opens with --- but is never closed by a matching ---') + ) + } + + $keywordIndex = Get-MermaidKeywordLineIndex -Line $lines -StartIndex $frontmatter.BodyStartIndex + if ($keywordIndex -lt 0) { + return Get-MermaidResult -Verdict 'Invalid' -DiagramType $null -Findings @( + (Get-MermaidFinding -Class 'MissingDiagramType' -Line (1 + $LineOffset) -Message 'no diagram-type keyword line was found after the frontmatter, directives, and comments') + ) + } + + $keywordLineNumber = $keywordIndex + 1 + $LineOffset + $resolved = Resolve-MermaidDiagramType -FirstLine $lines[$keywordIndex] + + if (-not $resolved.IsKnown) { + if (-not $resolved.IsPlausibleKeyword) { + return Get-MermaidResult -Verdict 'Invalid' -DiagramType $null -Findings @( + (Get-MermaidFinding -Class 'MissingDiagramType' -Line $keywordLineNumber -Message "the first line must declare a diagram type, but it begins with '$($resolved.Token)', which is not a diagram-type keyword") + ) + } + + # A near miss is a typo, not version drift, so it is named as a defect. The + # single-edit radius is what keeps this from swallowing genuinely new + # diagram types; see Resolve-MermaidMisspelledKeyword. + if (-not [string]::IsNullOrEmpty($resolved.MisspelledOf)) { + return Get-MermaidResult -Verdict 'Invalid' -DiagramType $resolved.Token -Findings @( + (Get-MermaidFinding -Class 'MisspelledDiagramType' -Line $keywordLineNumber -Message "the first line declares '$($resolved.Token)', which is one character away from the diagram keyword '$($resolved.MisspelledOf)'. Correct the keyword spelling.") + ) + } + + # Fail-open item 1: the version-drift safety valve. An out-of-date keyword + # allowlist costs a warning, never a false rejection. + return Get-MermaidResult -Verdict 'NotJudged' -DiagramType $resolved.Token -Warnings @( + "'$($resolved.Token)' is not in the Mermaid $(Get-MermaidGrammarVersion) diagram-type allowlist. It is shaped like a diagram keyword, so the body was not judged. Confirm the keyword against the Mermaid documentation and update .claude/lib/mermaid/MermaidGrammar.psm1 if it is a newer diagram type." + ) + } + + $diagramType = $resolved.Type + if (-not $resolved.IsVerified) { + # Fail-open item 6: the keyword resolves but its exact form was never + # verified against the pinned documentation, so the body is not judged. + return Get-MermaidResult -Verdict 'NotJudged' -DiagramType $diagramType -Warnings @( + "'$($resolved.Token)' is a keyword-accept entry whose exact first-line form was not verified against the pinned Mermaid $(Get-MermaidGrammarVersion) documentation, so the body was not judged." + ) + } + + if (-not (Test-MermaidDeepCheckedType -DiagramType $diagramType)) { + # Fail-open items 2 and 7: free-text and plugin-backed grammars are + # keyword-checked only. + return Get-MermaidResult -Verdict 'Valid' -DiagramType $diagramType + } + + $bodyStartIndex = $keywordIndex + 1 + $hasBody = $false + for ($index = $bodyStartIndex; $index -lt $lines.Count; $index++) { + $scan = Get-MermaidLineScan -Line $lines[$index] + if (-not ($scan.IsBlank -or $scan.IsComment -or $scan.IsDirective)) { + $hasBody = $true + break + } + } + if (-not $hasBody) { + return Get-MermaidResult -Verdict 'Invalid' -DiagramType $diagramType -Findings @( + (Get-MermaidFinding -Class 'EmptyDiagramBody' -Line $keywordLineNumber -Message "the '$diagramType' diagram declares a type but has no statements after the keyword line") + ) + } + + $findings = @(Get-MermaidBodyFinding -Line $lines -BodyStartIndex $bodyStartIndex -DiagramType $diagramType -LineOffset $LineOffset) + if ($findings.Count -gt 0) { + return Get-MermaidResult -Verdict 'Invalid' -DiagramType $diagramType -Findings $findings + } + + return Get-MermaidResult -Verdict 'Valid' -DiagramType $diagramType +} + +# Get-MermaidFenceBlock and Split-MermaidTextLine are re-exported from the nested +# MermaidMarkdownFences module so a consumer that imports this one module gets the whole +# diagram-extraction surface. A nested module's commands are visible to this module only +# unless they are named here explicitly. +Export-ModuleMember -Function ` + Get-MermaidFenceBlock, ` + Split-MermaidTextLine, ` + Get-MermaidFinding, ` + Get-MermaidFrontmatter, ` + Test-MermaidManagedDiagram, ` + Get-MermaidKeywordLineIndex, ` + Get-MermaidArrowFinding, ` + Get-MermaidBodyFinding, ` + Get-MermaidResult, ` + Test-MermaidDiagram diff --git a/.claude/lib/orchestrator-state/OrchestratorState.psm1 b/.claude/lib/orchestrator-state/OrchestratorState.psm1 index 9d75faeb4..7d9528804 100644 --- a/.claude/lib/orchestrator-state/OrchestratorState.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorState.psm1 @@ -19,15 +19,14 @@ Every public function FAILS CLOSED: a missing checkpoint file, invalid JSON, a missing required key, an invalid step status, or an unmet readiness condition all yield a non-zero ExitCode with a non-empty Output message. The Python validator - remains the authoritative reference; this module is the destination-runtime - mirror used only when the Python module is not importable. - - This module also hosts the capability-detection probe - (Test-PythonOrchestratorValidatorAvailable), shared by both pushed-down hooks - (.claude/hooks/enforce-pr-author-skill.ps1 and - .claude/hooks/validate-orchestrator-output.ps1) so neither hook duplicates it - locally, and the PR-creation preflight orchestration helper - (Invoke-OrchestratorStatePreflight) consumed by enforce-pr-author-skill.ps1. + is the parity reference this module is measured against; as of issue #475 it is + not consulted at runtime, and this module is the only implementation the hooks + run in every repository, drm-copilot included. + + This module also hosts the PR-creation preflight orchestration helper + (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. #> Set-StrictMode -Version Latest @@ -222,7 +221,7 @@ function Get-OrchestratorStateField { [string] $Name ) - $names = @($State.PSObject.Properties.Name) + $names = @($State.PSObject.Properties | ForEach-Object { $_.Name }) if ($names -contains $Name) { return @{ Present = $true; Value = $State.$Name } } @@ -253,7 +252,7 @@ function Get-OrchestratorStateBasePresenceError { ) $errors = [System.Collections.Generic.List[string]]::new() - $names = @($State.PSObject.Properties.Name) + $names = @($State.PSObject.Properties | ForEach-Object { $_.Name }) # Require every canonical top-level field; a missing key is reported individually # so the operator sees exactly which fields are absent. @@ -343,41 +342,14 @@ function Get-OrchestratorStatePrCreationReadinessError { return $errors.ToArray() } -function Test-PythonOrchestratorValidatorAvailable { - <# - .SYNOPSIS - Probe whether the authoritative Python orchestrator-state validator is importable. - .DESCRIPTION - Capability-detection seam. Returns $true only when - ``python -c "import scripts.dev_tools.validate_orchestration_artifacts"`` exits 0, - indicating the authoritative Python validator ships in this repository (drm-copilot). - Returns $false on any non-zero exit or error, so a consumer repository that received - only the pushed-down `.claude` pack (no `scripts/dev_tools`) routes to the portable - PowerShell module. Any probe failure routes to the portable path, which itself fails - closed on bad checkpoints, preserving fail-closed semantics in both branches. Tests - mock this seam directly; they never mock `python`. - .OUTPUTS - System.Boolean - #> - [CmdletBinding()] - [OutputType([bool])] - param() - - try { - & python -c 'import scripts.dev_tools.validate_orchestration_artifacts' 2>&1 | Out-Null - return ($LASTEXITCODE -eq 0) - } catch { - return $false - } -} - function Test-OrchestratorStatePrCreationReadiness { <# .SYNOPSIS Validate a checkpoint is ready for the first `gh pr create` of a branch. .DESCRIPTION - Public entry point used by the pushed-down enforce-pr-author-skill hook when - the authoritative Python validator is not importable. Loads the checkpoint + Public entry point consumed by the preflight seam that the pushed-down + enforce-pr-author-skill hook runs. It is the only implementation: there is no + alternative branch and no capability detection. Loads the checkpoint (fail-closed on missing file / invalid JSON), runs the base-presence check (required keys, step-status validity, blocked_reason validity), then runs the PR-creation-readiness parity check. Returns a hashtable compatible with the @@ -422,11 +394,14 @@ function Invoke-OrchestratorStatePreflight { .DESCRIPTION Shared by the pushed-down enforce-pr-author-skill hook. Mirrors Invoke-RoutingContractValidation (.claude/hooks/validate-orchestrator-output.ps1): - an injectable subprocess scriptblock seam defaults to ``python -m - scripts.dev_tools.validate_orchestration_artifacts orchestrator-state <CheckpointPath> - --require-pr-creation-ready``. A missing checkpoint or --require-pr-creation-ready failure - both surface via the validator's non-zero exit/stderr text; no separate file-existence check - is made, validating pre-PR-creation readiness (steps 5-8, blocked_reason) not full completion. + an injectable scriptblock seam whose default runs the portable in-process validation + and starts no subprocess. As of issue #475 there is no capability detection and no + alternative branch: the default seam runs the ported unconditional-block (U family) + checks followed by Test-OrchestratorStatePrCreationReadiness, which together match the + Python plain-call-plus---require-pr-creation-ready surface. A missing checkpoint or a + readiness failure both surface via the non-zero exit code and error text; no separate + file-existence check is made, validating pre-PR-creation readiness (steps 5-8, + blocked_reason) not full completion. .PARAMETER CheckpointPath The path to the orchestrator-state checkpoint JSON file. Callers pass their own checkpoint-path variable explicitly; the default below is only used when a caller omits @@ -443,22 +418,40 @@ function Invoke-OrchestratorStatePreflight { [Parameter(Mandatory = $false)] [scriptblock] $Invoker = { param($Path) - # Capability detection: use the authoritative Python CLI when - # scripts.dev_tools is importable (drm-copilot); otherwise fall back to - # the portable PowerShell function that lives alongside this one in the - # pushed-down pack. - if (Test-PythonOrchestratorValidatorAvailable) { - $output = & python -m scripts.dev_tools.validate_orchestration_artifacts ` - orchestrator-state $Path --require-pr-creation-ready 2>&1 - [pscustomobject]@{ - ExitCode = $LASTEXITCODE - Output = ($output | Out-String) - } + # The portable in-process path is the only path. Import the U-family + # aggregator lazily and only when its function is not already available, + # so a repeated call (or a test that pre-imports and mocks it) does not + # reload the module, and so this module does not import a sibling that + # imports it back at load time. + if (-not (Get-Command -Name Get-OrchestratorStateUnconditionalError -ErrorAction SilentlyContinue)) { + Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateUnconditional.psm1') -Force + } + + # Fail closed when the checkpoint cannot be loaded, before either check runs. + $loaded = Get-OrchestratorStateCheckpoint -CheckpointPath $Path + if (-not $loaded.Ok) { + [pscustomobject]@{ ExitCode = 1; Output = $loaded.Error } } else { - $portable = Test-OrchestratorStatePrCreationReadiness -CheckpointPath $Path - [pscustomobject]@{ - ExitCode = $portable.ExitCode - Output = $portable.Output + # The unconditional block, then the PR-creation-readiness gate: together + # the equivalent of the Python plain call plus --require-pr-creation-ready. + $errors = [System.Collections.Generic.List[string]]::new() + $errors.AddRange([string[]]@(Get-OrchestratorStateUnconditionalError -State $loaded.State)) + + # The readiness entry point re-runs the base-presence checks the + # unconditional block already ran, so its lines are merged rather than + # appended wholesale: each error string is emitted exactly once, the same + # single-emission rule the completion module applies to its M3 leg. + $readiness = Test-OrchestratorStatePrCreationReadiness -CheckpointPath $Path + foreach ($line in ([string]$readiness.Output -split "`r?`n")) { + if (-not [string]::IsNullOrWhiteSpace($line) -and -not $errors.Contains($line)) { + $errors.Add($line) + } + } + + if ($errors.Count -gt 0) { + [pscustomobject]@{ ExitCode = 1; Output = ($errors -join [System.Environment]::NewLine) } + } else { + [pscustomobject]@{ ExitCode = 0; Output = '' } } } } @@ -471,7 +464,7 @@ function Invoke-OrchestratorStatePreflight { # before .Name is ever accessed. $resultPropertyNames = @() if ($null -ne $result -and @($result.PSObject.Properties).Count -gt 0) { - $resultPropertyNames = @($result.PSObject.Properties.Name) + $resultPropertyNames = @($result.PSObject.Properties | ForEach-Object { $_.Name }) } $exitCode = 0 if ($resultPropertyNames -contains 'ExitCode') { $exitCode = [int]$result.ExitCode } @@ -484,14 +477,12 @@ function Invoke-OrchestratorStatePreflight { # Export the public readiness entry point plus the reusable load, field-accessor, # and base-presence primitives so the sibling OrchestratorStateCompletion module can # consume them via Import-Module without duplicating the shared parsing and -# base-check logic. Test-PythonOrchestratorValidatorAvailable and -# Invoke-OrchestratorStatePreflight are exported so both pushed-down hooks -# (enforce-pr-author-skill.ps1, validate-orchestrator-output.ps1) can consume them -# without duplicating the capability probe or the PR-creation preflight orchestration. +# base-check logic. Invoke-OrchestratorStatePreflight is exported so the pushed-down +# enforce-pr-author-skill.ps1 hook can consume the PR-creation preflight +# orchestration without duplicating it. Export-ModuleMember -Function ` Test-OrchestratorStatePrCreationReadiness, ` Get-OrchestratorStateCheckpoint, ` Get-OrchestratorStateField, ` Get-OrchestratorStateBasePresenceError, ` - Test-PythonOrchestratorValidatorAvailable, ` Invoke-OrchestratorStatePreflight diff --git a/.claude/lib/orchestrator-state/OrchestratorStateCheckpointValue.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateCheckpointValue.psm1 new file mode 100644 index 000000000..4ce6a25b0 --- /dev/null +++ b/.claude/lib/orchestrator-state/OrchestratorStateCheckpointValue.psm1 @@ -0,0 +1,383 @@ +<# +.SYNOPSIS + Shared checkpoint-value primitives for the portable orchestrator-state parity checks. + +.DESCRIPTION + Sibling helper module for the orchestrator-state parity family, created under + the plan's pre-authorized split so no parity module exceeds the repository's + 500-line file cap. It holds the primitives every ported check family needs and + guarantees there is exactly ONE implementation of each: + + - Test-CheckpointObjectValue / Test-CheckpointListValue - the JSON shape + predicates mirroring Python's isinstance(value, dict) / isinstance(value, list). + - Get-CheckpointObjectMember - the strict-mode-safe member accessor that + distinguishes an absent key from a present null, mirroring the Python + distinction between `key not in mapping` and `mapping.get(key) is None`. + - Get-CheckpointOrdinalSortedName - ordinal key ordering matching Python's + sorted(), which PowerShell's culture-aware Sort-Object would not reproduce. + - Test-PythonZeroEquivalent - Python's `value == 0` semantics, under which + boolean False is zero-equivalent. + - ConvertTo-PythonDisplayText / ConvertTo-PythonReprText - the str() and + repr() interpolation renderers the inventory error templates require. + + 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. +#> + +Set-StrictMode -Version Latest + +# 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. +$script:JSON_NUMERIC_TYPES = @([int], [long], [double], [decimal], [single]) + + +function Test-CheckpointObjectValue { + <# + .SYNOPSIS + Report whether a deserialized JSON value is an object (mapping). + .DESCRIPTION + Shape predicate mirroring Python's isinstance(value, dict). + ConvertFrom-Json materializes a JSON object as a PSCustomObject. + .PARAMETER Value + The deserialized JSON value to classify. May be $null. + .OUTPUTS + System.Boolean - $true only for a JSON object. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + return ($Value -is [System.Management.Automation.PSCustomObject]) +} + +function Test-CheckpointListValue { + <# + .SYNOPSIS + Report whether a deserialized JSON value is an array (list). + .DESCRIPTION + Shape predicate mirroring Python's isinstance(value, list). A JSON string + is deliberately not a list, matching Python, where a string is a sequence + but not a list. + .PARAMETER Value + The deserialized JSON value to classify. May be $null. + .OUTPUTS + System.Boolean - $true only for a JSON array. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + return ($Value -is [System.Array]) +} + +function Get-CheckpointObjectMemberName { + <# + .SYNOPSIS + List the member names of a deserialized JSON object. + .DESCRIPTION + Enumerating `$Owner.PSObject.Properties.Name` directly throws under + Set-StrictMode when the object carries zero properties, because member + enumeration over an empty collection has no Name member. This helper + projects the names one property at a time so an empty JSON object ({}) + yields an empty name list instead of a terminating error. + .PARAMETER Owner + The deserialized JSON value expected to be an object. May be $null. + .OUTPUTS + System.String[] - the member names, empty for a non-object or empty object. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Owner + ) + + if (-not (Test-CheckpointObjectValue -Value $Owner)) { return [string[]]@() } + return [string[]]@($Owner.PSObject.Properties | ForEach-Object { $_.Name }) +} + +function Get-CheckpointObjectMember { + <# + .SYNOPSIS + Read a member from a deserialized JSON object, absent distinguished from null. + .DESCRIPTION + Accessor that safely reads a named property under Set-StrictMode, where + touching an undefined property would otherwise throw. Returns both presence + and value so callers can reproduce the Python distinction between + `key not in mapping` and `mapping.get(key) is None`. A non-object owner + reports the member as absent rather than throwing. + .PARAMETER Owner + The deserialized JSON value expected to be an object. May be $null. + .PARAMETER Name + The member name to read. + .OUTPUTS + System.Collections.Hashtable with keys Present (bool) and Value (object). + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Owner, + + [Parameter(Mandatory = $true)] + [string] $Name + ) + + if (-not (Test-CheckpointObjectValue -Value $Owner)) { + return @{ Present = $false; Value = $null } + } + + $names = @(Get-CheckpointObjectMemberName -Owner $Owner) + if ($names -contains $Name) { + return @{ Present = $true; Value = $Owner.$Name } + } + return @{ Present = $false; Value = $null } +} + +function Get-CheckpointOrdinalSortedName { + <# + .SYNOPSIS + Sort key names by ordinal comparison, matching Python's sorted(). + .DESCRIPTION + Python's sorted() over strings compares code points; PowerShell's + Sort-Object is culture-aware and case-insensitive by default, which would + reorder mixed-case key names differently. This helper pins the comparison + to StringComparer.Ordinal so unsupported-key error ordering is identical + across the two runtimes. + .PARAMETER Name + The key names to sort. May be empty. + .OUTPUTS + System.String[] - the ordinally sorted names. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]] $Name + ) + + $sorted = [string[]]@($Name) + [Array]::Sort($sorted, [System.StringComparer]::Ordinal) + return $sorted +} + +function Test-PythonZeroEquivalent { + <# + .SYNOPSIS + Report whether a value compares equal to Python's integer 0. + .DESCRIPTION + Reproduces Python's `value == 0` for deserialized JSON values: integer 0 + and float 0.0 compare equal, and so does boolean False because Python + treats False as 0. None, a non-zero number, True, a string, and any + structure do not. + .PARAMETER Value + The deserialized JSON value to compare. May be $null. + .OUTPUTS + System.Boolean - $true when the value equals Python's 0. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { return $false } + + # Python's False == 0 holds, so False is zero-equivalent and True is not. The + # boolean branch precedes the numeric branch because a CLR boolean is not one + # of the JSON numeric types. + if ($Value -is [bool]) { return (-not $Value) } + + # Compare the exact CLR type rather than using -is so no boxed value satisfies + # a numeric test through an implicit conversion. + foreach ($numericType in $script:JSON_NUMERIC_TYPES) { + if ($Value.GetType() -eq $numericType) { return ([double]$Value -eq 0.0) } + } + return $false +} + +function Test-PythonValueEqual { + <# + .SYNOPSIS + Compare two deserialized JSON values the way Python's == would. + .DESCRIPTION + Shared equality predicate for the resolved-key comparisons in the codex + receipt families, where a checkpoint value is compared against a resolver + output. It reproduces Python's value equality over the JSON value space: + None equals only None, booleans compare by value and never to a string, + strings compare ordinally (Python is case-sensitive where PowerShell's + default -eq is not), numbers compare numerically, and lists compare + element-wise in order. A mapping compares by member name and value. + .PARAMETER Actual + The value read from the checkpoint. May be $null. + .PARAMETER Expected + The value produced by the resolver. May be $null. + .OUTPUTS + System.Boolean - $true when the two values are Python-equal. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Actual, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Expected + ) + + if ($null -eq $Expected) { return ($null -eq $Actual) } + if ($null -eq $Actual) { return $false } + + # Booleans are compared first and never cross-compare with strings, because a + # resolver boolean must not match the string "True" recorded in a checkpoint. + if ($Expected -is [bool] -or $Actual -is [bool]) { + if (-not ($Expected -is [bool]) -or -not ($Actual -is [bool])) { return $false } + return ([bool]$Expected -eq [bool]$Actual) + } + + # Strings compare ordinally so a case difference is a real difference, which + # PowerShell's default case-insensitive -eq would hide. + if ($Expected -is [string] -or $Actual -is [string]) { + if (-not ($Expected -is [string]) -or -not ($Actual -is [string])) { return $false } + return ([string]::Equals([string]$Expected, [string]$Actual, [System.StringComparison]::Ordinal)) + } + + # Lists compare element-wise in order; length differs, values differ. + if ((Test-CheckpointListValue -Value $Expected) -or (Test-CheckpointListValue -Value $Actual)) { + if (-not (Test-CheckpointListValue -Value $Expected) -or -not (Test-CheckpointListValue -Value $Actual)) { return $false } + $expectedItems = @($Expected) + $actualItems = @($Actual) + if ($expectedItems.Count -ne $actualItems.Count) { return $false } + for ($i = 0; $i -lt $expectedItems.Count; $i++) { + if (-not (Test-PythonValueEqual -Actual $actualItems[$i] -Expected $expectedItems[$i])) { return $false } + } + return $true + } + + # Mappings compare by member name and value, order-independently. + if ((Test-CheckpointObjectValue -Value $Expected) -or (Test-CheckpointObjectValue -Value $Actual)) { + if (-not (Test-CheckpointObjectValue -Value $Expected) -or -not (Test-CheckpointObjectValue -Value $Actual)) { return $false } + $expectedNames = @(Get-CheckpointObjectMemberName -Owner $Expected) + $actualNames = @(Get-CheckpointObjectMemberName -Owner $Actual) + if ($expectedNames.Count -ne $actualNames.Count) { return $false } + foreach ($name in $expectedNames) { + if ($actualNames -notcontains $name) { return $false } + $pair = @{ + Expected = (Get-CheckpointObjectMember -Owner $Expected -Name $name).Value + Actual = (Get-CheckpointObjectMember -Owner $Actual -Name $name).Value + } + if (-not (Test-PythonValueEqual -Actual $pair.Actual -Expected $pair.Expected)) { return $false } + } + return $true + } + + # Everything remaining is a scalar number, compared numerically. + return ($Expected -eq $Actual) +} + +function ConvertTo-PythonDisplayText { + <# + .SYNOPSIS + Render a deserialized JSON value the way Python's str() would. + .DESCRIPTION + Renderer for the inventory error templates that interpolate a raw value + ({value}). Reproduces Python's str() over the JSON value space: None, + True/False, bare strings, invariant-culture numbers, list literals, and + dict literals. Container elements render with repr(), matching Python, + where str() of a container calls repr() on its members. + .PARAMETER Value + The deserialized JSON value to render. May be $null. + .OUTPUTS + System.String - the rendered text. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { return 'None' } + if ($Value -is [bool]) { if ($Value) { return 'True' } else { return 'False' } } + if ($Value -is [string]) { return [string]$Value } + + # A JSON array renders as a Python list literal whose elements use repr(). + if (Test-CheckpointListValue -Value $Value) { + $items = @(foreach ($item in $Value) { ConvertTo-PythonReprText -Value $item }) + return '[' + ($items -join ', ') + ']' + } + + # A JSON object renders as a Python dict literal with repr() keys and values. + if (Test-CheckpointObjectValue -Value $Value) { + $pairs = @(foreach ($property in $Value.PSObject.Properties) { + (ConvertTo-PythonReprText -Value $property.Name) + ': ' + (ConvertTo-PythonReprText -Value $property.Value) + }) + return '{' + ($pairs -join ', ') + '}' + } + + # Numbers and any remaining scalar render culture-invariantly so a non-US host + # does not emit a comma decimal separator into a parity error string. + return [string]::Format([System.Globalization.CultureInfo]::InvariantCulture, '{0}', $Value) +} + +function ConvertTo-PythonReprText { + <# + .SYNOPSIS + Render a deserialized JSON value the way Python's repr() would. + .DESCRIPTION + Renderer for the inventory templates that interpolate {value!r}. Only + strings differ from str(): repr() quotes them and escapes backslashes and + the quote character. Known divergence, recorded repo-wide at + docs/features/potential/2026-08-07-python-repr-quote-selection-divergence.md: + this renderer always selects single quotes, whereas CPython switches to + double quotes when the string contains a single quote and no double quote. + .PARAMETER Value + The deserialized JSON value to render. May be $null. + .OUTPUTS + System.String - the rendered text. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + if ($Value -is [string]) { + $escaped = ([string]$Value).Replace('\', '\\').Replace("'", "\'") + return "'" + $escaped + "'" + } + return (ConvertTo-PythonDisplayText -Value $Value) +} + +# Every primitive is exported so each sibling parity module consumes one shared +# implementation of the shape predicates, the member accessor, the ordinal sort, +# the Python zero-equivalence rule, and the str()/repr() renderers. +Export-ModuleMember -Function ` + Test-CheckpointObjectValue, ` + Test-CheckpointListValue, ` + Get-CheckpointObjectMemberName, ` + Get-CheckpointObjectMember, ` + Get-CheckpointOrdinalSortedName, ` + Test-PythonZeroEquivalent, ` + Test-PythonValueEqual, ` + ConvertTo-PythonDisplayText, ` + ConvertTo-PythonReprText diff --git a/.claude/lib/orchestrator-state/OrchestratorStateCodexModelReceipts.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateCodexModelReceipts.psm1 new file mode 100644 index 000000000..551039b9c --- /dev/null +++ b/.claude/lib/orchestrator-state/OrchestratorStateCodexModelReceipts.psm1 @@ -0,0 +1,297 @@ +<# +.SYNOPSIS + Portable codex_model_routing_receipts per-entry checks (inventory family U6.X). + +.DESCRIPTION + Destination-runtime PowerShell port of + `scripts/dev_tools/_orchestrator_state_codex_model_routing.py`, covering + parity-inventory rows U6.X1 through U6.X11: the list and object shape, the ten + required keys, the non-empty phase, the resolver-invalid-inputs surface, the + ceiling monotonicity rule, the three ceiling-transition rules, and the + resolved-key comparison. + + SINGLE-IMPLEMENTATION RULE. The expected deployment is obtained by calling + `Resolve-CodexDeployment` from `.claude/lib/codex-routing/CodexDeployment.psm1`. + The profile table, the C3 overlay rule, and the forced-persona rule are never + re-implemented here. + + Row U6.X11 renders both sides of a mismatch with Python `repr()` semantics + (`{expected!r}` / `{actual!r}`), so it uses the shared `ConvertTo-PythonReprText` + renderer. Row U6.X5 interpolates the resolver's exception text with Python + `str()` semantics, so the resolver's ArgumentException Message is used verbatim. + + Every function is pure: it reads no file, starts no process, and never mutates + its input. +#> + +Set-StrictMode -Version Latest + +# 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 +$script:CodexDeploymentModulePath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath (Join-Path -Path 'codex-routing' -ChildPath 'CodexDeployment.psm1') +Import-Module $script:CodexDeploymentModulePath -Force + +# The checkpoint key this family validates. +$script:CODEX_MODEL_ROUTING_RECEIPTS_KEY = 'codex_model_routing_receipts' + +# The ten keys every receipt must carry, and the nine of them the resolver +# reproduces. Pinned to _REQUIRED_KEYS / _RESOLVED_KEYS in the Python reference; +# `phase` is checkpoint-only bookkeeping and is not resolver output. +$script:REQUIRED_RECEIPT_KEYS = @( + 'logical_agent', + 'deployment_agent', + 'phase', + 'complexity_band', + 'execution_context', + 'orchestration_complexity_ceiling', + 'c3_overlay_applied', + 'c3_overlay_reason', + 'model', + 'model_reasoning_effort' +) +$script:RESOLVED_RECEIPT_KEYS = @($script:REQUIRED_RECEIPT_KEYS | Where-Object { $_ -ne 'phase' }) + +# The complexity-band ordering used by the ceiling monotonicity comparison. +$script:BAND_ORDER = @('C1', 'C2', 'C3', 'C4') + + +function Get-CodexCeilingTransitionError { + <# + .SYNOPSIS + Return one receipt's ceiling-transition errors (rows U6.X7-U6.X10). + .DESCRIPTION + Private helper mirroring _validate_ceiling_transition. Transition evidence + is required exactly when the orchestration ceiling rises: absent when it + does not rise, and otherwise an object recording the exact from/to pair and + a non-empty unique list of affected delegation ids. + .PARAMETER Receipt + The deserialized receipt object. + .PARAMETER Prefix + The error-message prefix for this receipt position. + .PARAMETER PreviousCeiling + The previous receipt's resolved ceiling, or $null for the first receipt. + .PARAMETER CurrentCeiling + This receipt's resolved ceiling. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $Receipt, + + [Parameter(Mandatory = $true)] + [string] $Prefix, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string] $PreviousCeiling, + + [Parameter(Mandatory = $true)] + [string] $CurrentCeiling + ) + + $errors = [System.Collections.Generic.List[string]]::new() + $transition = (Get-CheckpointObjectMember -Owner $Receipt -Name 'ceiling_transition').Value + + # U6.X7: with no previous ceiling, or an unchanged ceiling, transition + # evidence must be absent entirely. + if ([string]::IsNullOrEmpty($PreviousCeiling) -or ($CurrentCeiling -ceq $PreviousCeiling)) { + if ($null -ne $transition) { + $errors.Add("$Prefix.ceiling_transition must be absent unless the ceiling rises.") + } + return $errors.ToArray() + } + + # U6.X8: a risen ceiling requires an object recording the increase. + if (-not (Test-CheckpointObjectValue -Value $transition)) { + $errors.Add("$Prefix.ceiling_transition must record a ceiling increase.") + return $errors.ToArray() + } + + # U6.X9: the recorded from/to pair must be the actual transition. + $from = (Get-CheckpointObjectMember -Owner $transition -Name 'from').Value + $to = (Get-CheckpointObjectMember -Owner $transition -Name 'to').Value + if (-not (Test-PythonValueEqual -Actual $from -Expected $PreviousCeiling) -or + -not (Test-PythonValueEqual -Actual $to -Expected $CurrentCeiling)) { + $errors.Add("$Prefix.ceiling_transition must record $PreviousCeiling to $CurrentCeiling.") + } + + # U6.X10: the affected delegation ids must be a non-empty list of distinct, + # non-blank strings. A non-list value is treated as empty, matching Python. + $affected = (Get-CheckpointObjectMember -Owner $transition -Name 'affected_delegation_ids').Value + $affectedItems = @() + if (Test-CheckpointListValue -Value $affected) { $affectedItems = @($affected) } + $malformed = $affectedItems.Count -eq 0 + if (-not $malformed) { + $distinct = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($item in $affectedItems) { + if (-not ($item -is [string]) -or [string]::IsNullOrWhiteSpace([string]$item)) { + $malformed = $true + break + } + [void]$distinct.Add([string]$item) + } + if (-not $malformed -and $distinct.Count -ne $affectedItems.Count) { $malformed = $true } + } + if ($malformed) { + $errors.Add("$Prefix.ceiling_transition.affected_delegation_ids must be a non-empty unique string list.") + } + + return $errors.ToArray() +} + +function Get-CodexModelRoutingResolvedKeyError { + <# + .SYNOPSIS + Return the resolved-key mismatch errors for one receipt (row U6.X11). + .DESCRIPTION + Private helper comparing each of the nine resolver-reproduced keys against + the resolver output. Both sides render with Python repr() semantics because + the inventory template uses {expected!r} and {actual!r}. + .PARAMETER Receipt + The deserialized receipt object. + .PARAMETER Prefix + The error-message prefix for this receipt position. + .PARAMETER Expected + The resolver output hashtable. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $Receipt, + + [Parameter(Mandatory = $true)] + [string] $Prefix, + + [Parameter(Mandatory = $true)] + [hashtable] $Expected + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # Compare every resolver-reproduced key so a receipt reports all of its + # mismatches at once rather than only the first. + foreach ($key in $script:RESOLVED_RECEIPT_KEYS) { + $actual = (Get-CheckpointObjectMember -Owner $Receipt -Name $key).Value + if (-not (Test-PythonValueEqual -Actual $actual -Expected $Expected[$key])) { + $expectedText = ConvertTo-PythonReprText -Value $Expected[$key] + $actualText = ConvertTo-PythonReprText -Value $actual + $errors.Add("$Prefix.$key must be $expectedText, found $actualText.") + } + } + + return $errors.ToArray() +} + +function Get-OrchestratorStateCodexModelRoutingReceiptError { + <# + .SYNOPSIS + Return the codex_model_routing_receipts errors (rows U6.X1-U6.X11). + .DESCRIPTION + Public entry mirroring validate_codex_model_routing_receipts. Walks the + receipt array in order, carrying the previous resolved ceiling forward so + the monotonicity and transition rules can be applied, and reports every + malformed receipt with its own index. + + Control flow reproduces the Python reference exactly: missing keys stop + that receipt; a resolver failure stops that receipt and leaves the carried + ceiling unchanged; a monotonicity violation suppresses the transition check + for that receipt but still advances the carried ceiling. + .PARAMETER Value + The raw deserialized value of the codex_model_routing_receipts key. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # U6.X1: the caller invokes this only when the key is present, so a non-list + # value is itself the error and nothing further can be inspected. + if (-not (Test-CheckpointListValue -Value $Value)) { + $errors.Add("Checkpoint $($script:CODEX_MODEL_ROUTING_RECEIPTS_KEY) must be a list when present.") + return $errors.ToArray() + } + + $previousCeiling = $null + $index = 0 + foreach ($item in @($Value)) { + $prefix = "Checkpoint $($script:CODEX_MODEL_ROUTING_RECEIPTS_KEY)[$index]" + $index++ + + # U6.X2: a non-object entry has no keys to inspect. + if (-not (Test-CheckpointObjectValue -Value $item)) { + $errors.Add("$prefix must be an object.") + continue + } + + # U6.X3: a receipt missing any required key stops here, because the + # resolver cannot be called without complete inputs. + $names = @(Get-CheckpointObjectMemberName -Owner $item) + $missing = @($script:REQUIRED_RECEIPT_KEYS | Where-Object { $names -notcontains $_ }) + if ($missing.Count -gt 0) { + $errors.Add("$prefix missing required keys: $($missing -join ', ').") + continue + } + + # U6.X4: the phase is checkpoint bookkeeping; a malformed phase is + # reported but does not stop the resolver comparison. + $phase = (Get-CheckpointObjectMember -Owner $item -Name 'phase').Value + if (-not ($phase -is [string]) -or [string]::IsNullOrWhiteSpace([string]$phase)) { + $errors.Add("$prefix.phase must be a non-empty string.") + } + + # U6.X5: resolve through the single Codex deployment resolver. Only the + # ValueError-equivalent surface is caught, matching the Python except + # clause; every input is coerced with Python str() semantics first. + $expected = $null + try { + $expected = Resolve-CodexDeployment ` + -LogicalAgent (ConvertTo-PythonDisplayText -Value (Get-CheckpointObjectMember -Owner $item -Name 'logical_agent').Value) ` + -ComplexityBand (ConvertTo-PythonDisplayText -Value (Get-CheckpointObjectMember -Owner $item -Name 'complexity_band').Value) ` + -ExecutionContext (ConvertTo-PythonDisplayText -Value (Get-CheckpointObjectMember -Owner $item -Name 'execution_context').Value) ` + -OrchestrationComplexityCeiling (ConvertTo-PythonDisplayText -Value (Get-CheckpointObjectMember -Owner $item -Name 'orchestration_complexity_ceiling').Value) + } catch [System.ArgumentException] { + $errors.Add("$prefix has invalid routing inputs: $($_.Exception.Message)") + continue + } + + # U6.X6 and the transition rules. A ceiling that drops is a monotonicity + # violation and suppresses the transition check for this receipt; the + # carried ceiling advances either way. + $currentCeiling = [string]$expected['orchestration_complexity_ceiling'] + if ($null -ne $previousCeiling -and + ($script:BAND_ORDER.IndexOf($currentCeiling) -lt $script:BAND_ORDER.IndexOf([string]$previousCeiling))) { + $errors.Add("$prefix.orchestration_complexity_ceiling must be monotonic; found $currentCeiling after $previousCeiling.") + } else { + $errors.AddRange([string[]]@( + Get-CodexCeilingTransitionError -Receipt $item -Prefix $prefix ` + -PreviousCeiling $previousCeiling -CurrentCeiling $currentCeiling + )) + } + $previousCeiling = $currentCeiling + + # U6.X11: every resolver-reproduced key must match the resolver output. + $errors.AddRange([string[]]@(Get-CodexModelRoutingResolvedKeyError -Receipt $item -Prefix $prefix -Expected $expected)) + } + + return $errors.ToArray() +} + +# Only the family entry point is exported; the transition and resolved-key helpers +# stay private so the ordered, ceiling-carrying walk cannot be bypassed. +Export-ModuleMember -Function Get-OrchestratorStateCodexModelRoutingReceiptError diff --git a/.claude/lib/orchestrator-state/OrchestratorStateCodexTopologyReceipts.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateCodexTopologyReceipts.psm1 new file mode 100644 index 000000000..5a1feb1d5 --- /dev/null +++ b/.claude/lib/orchestrator-state/OrchestratorStateCodexTopologyReceipts.psm1 @@ -0,0 +1,298 @@ +<# +.SYNOPSIS + Portable codex_topology_receipts per-entry checks (inventory family U6.T). + +.DESCRIPTION + Destination-runtime PowerShell port of + `scripts/dev_tools/_orchestrator_state_codex_topology.py`, covering + parity-inventory rows U6.T1 through U6.T11: the list and object shape, the + thirteen required keys, the non-empty phase, the resolver input-type checks + (languages, the two file counts, the cross-cutting flag, the execution + context, and the root-persona enum), the resolver-invalid-inputs surface, and + the resolved-key comparison. + + SINGLE-IMPLEMENTATION RULE. The expected topology is obtained by calling + `Resolve-CodexTopology`, and the permitted root personas are read from + `Get-CodexForcedRootPersona`, both from + `.claude/lib/codex-routing/CodexTopology.psm1`. The language-budget table and + the escalation precedence are never re-implemented here. + + Row U6.T6 rejects a boolean where an integer is required, reproducing the + Python guard that exists because bool is a subclass of int. Row U6.T11 renders + both sides of a mismatch with Python `repr()` semantics, which for the + `languages` key means a Python list literal. Row U6.T10 interpolates the + resolver's exception text with Python `str()` semantics. + + Every function is pure: it reads no file, starts no process, and never mutates + its input. +#> + +Set-StrictMode -Version Latest + +# 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 +$script:CodexTopologyModulePath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath (Join-Path -Path 'codex-routing' -ChildPath 'CodexTopology.psm1') +Import-Module $script:CodexTopologyModulePath -Force + +# The checkpoint key this family validates. +$script:CODEX_TOPOLOGY_RECEIPTS_KEY = 'codex_topology_receipts' + +# The thirteen keys every receipt must carry, and the twelve of them the resolver +# reproduces. Pinned to _REQUIRED_KEYS / _RESOLVED_KEYS in the Python reference; +# `phase` is checkpoint-only bookkeeping and is not resolver output. +$script:REQUIRED_RECEIPT_KEYS = @( + 'phase', + 'execution_context', + 'languages', + 'production_file_count', + 'test_file_count', + 'cross_cutting', + 'root_persona', + 'route', + 'topology', + 'logical_agent', + 'routing_reason', + 'max_production_files', + 'max_test_files' +) +$script:RESOLVED_RECEIPT_KEYS = @($script:REQUIRED_RECEIPT_KEYS | Where-Object { $_ -ne 'phase' }) + +# The two file-count keys subject to the integer-not-boolean rule. +$script:FILE_COUNT_KEYS = @('production_file_count', 'test_file_count') + +# Rendered form of the Python sorted FORCED_ROOT_PERSONAS tuple, used verbatim in +# the root-persona message. The membership test itself reads the live set from the +# resolver module so the enum has one source. +$script:FORCED_ROOT_PERSONAS_PYTHON_TUPLE = "('epic-orchestrator', 'epic-planner')" + +# The integral CLR types a JSON integer can deserialize to. A CLR boolean is +# deliberately absent, matching the Python bool rejection. +$script:INTEGRAL_TYPES = @([int], [long], [short], [byte]) + + +function Get-CodexTopologyInputError { + <# + .SYNOPSIS + Return one receipt's resolver-input type errors (rows U6.T5-U6.T9). + .DESCRIPTION + Private helper mirroring _receipt_inputs. Every input the resolver + consumes is type-checked here first, in the Python order, so the resolver + is never called with a value it would reject by type. A non-empty result + means the receipt is skipped before resolution, matching the Python + `if inputs is None: continue` branch. + .PARAMETER Receipt + The deserialized receipt object. + .PARAMETER Prefix + The error-message prefix for this receipt position. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $Receipt, + + [Parameter(Mandatory = $true)] + [string] $Prefix + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # U6.T5: languages must be a list in which every member is a non-blank string. + $languages = (Get-CheckpointObjectMember -Owner $Receipt -Name 'languages').Value + $languagesValid = Test-CheckpointListValue -Value $languages + if ($languagesValid) { + foreach ($language in @($languages)) { + if (-not ($language -is [string]) -or [string]::IsNullOrWhiteSpace([string]$language)) { + $languagesValid = $false + break + } + } + } + if (-not $languagesValid) { + $errors.Add("$Prefix.languages must be a list of non-empty strings.") + } + + # U6.T6: both file counts must be integers, and a boolean is explicitly not an + # integer here even though Python's bool subclasses int. + foreach ($key in $script:FILE_COUNT_KEYS) { + $value = (Get-CheckpointObjectMember -Owner $Receipt -Name $key).Value + $isIntegral = $false + if ($null -ne $value -and -not ($value -is [bool])) { + foreach ($integralType in $script:INTEGRAL_TYPES) { + if ($value.GetType() -eq $integralType) { $isIntegral = $true; break } + } + } + if (-not $isIntegral) { + $errors.Add("$Prefix.$key must be an integer.") + } + } + + # U6.T7 and U6.T8: the cross-cutting flag and the execution context. + $crossCutting = (Get-CheckpointObjectMember -Owner $Receipt -Name 'cross_cutting').Value + if (-not ($crossCutting -is [bool])) { + $errors.Add("$Prefix.cross_cutting must be a boolean.") + } + $receiptExecutionContext = (Get-CheckpointObjectMember -Owner $Receipt -Name 'execution_context').Value + if (-not ($receiptExecutionContext -is [string])) { + $errors.Add("$Prefix.execution_context must be a string.") + } + + # U6.T9: root_persona is optional, but a present value must be a forced root + # persona. The permitted set is read from the resolver module, not restated. + $rootPersona = (Get-CheckpointObjectMember -Owner $Receipt -Name 'root_persona').Value + if ($null -ne $rootPersona) { + $permitted = @(Get-CodexForcedRootPersona) + if (-not ($rootPersona -is [string]) -or ($permitted -cnotcontains [string]$rootPersona)) { + $errors.Add("$Prefix.root_persona must be null or one of $($script:FORCED_ROOT_PERSONAS_PYTHON_TUPLE).") + } + } + + return $errors.ToArray() +} + +function Get-CodexTopologyResolvedKeyError { + <# + .SYNOPSIS + Return the resolved-key mismatch errors for one receipt (row U6.T11). + .DESCRIPTION + Private helper comparing each of the twelve resolver-reproduced keys + against the resolver output. Both sides render with Python repr() + semantics, which for the languages key produces a Python list literal. + .PARAMETER Receipt + The deserialized receipt object. + .PARAMETER Prefix + The error-message prefix for this receipt position. + .PARAMETER Expected + The resolver output hashtable. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $Receipt, + + [Parameter(Mandatory = $true)] + [string] $Prefix, + + [Parameter(Mandatory = $true)] + [hashtable] $Expected + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # Compare every resolver-reproduced key so a receipt reports all of its + # mismatches at once rather than only the first. + foreach ($key in $script:RESOLVED_RECEIPT_KEYS) { + $actual = (Get-CheckpointObjectMember -Owner $Receipt -Name $key).Value + if (-not (Test-PythonValueEqual -Actual $actual -Expected $Expected[$key])) { + $expectedText = ConvertTo-PythonReprText -Value $Expected[$key] + $actualText = ConvertTo-PythonReprText -Value $actual + $errors.Add("$Prefix.$key must be $expectedText, found $actualText.") + } + } + + return $errors.ToArray() +} + +function Get-OrchestratorStateCodexTopologyReceiptError { + <# + .SYNOPSIS + Return the codex_topology_receipts errors (inventory rows U6.T1-U6.T11). + .DESCRIPTION + Public entry mirroring validate_codex_topology_receipts. Each receipt is + validated independently, in order, and reported with its own index. + + Control flow reproduces the Python reference exactly: missing keys stop + that receipt before any type check; a malformed phase is reported but does + not stop the receipt; any resolver-input type error stops the receipt + before resolution; and a resolver failure stops the receipt before the + resolved-key comparison. + .PARAMETER Value + The raw deserialized value of the codex_topology_receipts key. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # U6.T1: the caller invokes this only when the key is present, so a non-list + # value is itself the error and nothing further can be inspected. + if (-not (Test-CheckpointListValue -Value $Value)) { + $errors.Add("Checkpoint $($script:CODEX_TOPOLOGY_RECEIPTS_KEY) must be a list when present.") + return $errors.ToArray() + } + + $index = 0 + foreach ($item in @($Value)) { + $prefix = "Checkpoint $($script:CODEX_TOPOLOGY_RECEIPTS_KEY)[$index]" + $index++ + + # U6.T2: a non-object entry has no keys to inspect. + if (-not (Test-CheckpointObjectValue -Value $item)) { + $errors.Add("$prefix must be an object.") + continue + } + + # U6.T3: a receipt missing any required key stops here, because the + # resolver cannot be called without complete inputs. + $names = @(Get-CheckpointObjectMemberName -Owner $item) + $missing = @($script:REQUIRED_RECEIPT_KEYS | Where-Object { $names -notcontains $_ }) + if ($missing.Count -gt 0) { + $errors.Add("$prefix missing required keys: $($missing -join ', ').") + continue + } + + # U6.T4: the phase is checkpoint bookkeeping; a malformed phase is + # reported but does not stop the resolver comparison. + $phase = (Get-CheckpointObjectMember -Owner $item -Name 'phase').Value + if (-not ($phase -is [string]) -or [string]::IsNullOrWhiteSpace([string]$phase)) { + $errors.Add("$prefix.phase must be a non-empty string.") + } + + # U6.T5-U6.T9: any resolver-input type error stops this receipt, so the + # resolver is never handed a value it would reject by type. + $inputErrors = @(Get-CodexTopologyInputError -Receipt $item -Prefix $prefix) + if ($inputErrors.Count -gt 0) { + $errors.AddRange([string[]]$inputErrors) + continue + } + + # U6.T10: resolve through the single Codex topology resolver. Only the + # ValueError-equivalent surface is caught, matching the Python except. + $expected = $null + try { + $expected = Resolve-CodexTopology ` + -Language (Get-CheckpointObjectMember -Owner $item -Name 'languages').Value ` + -ProductionFileCount (Get-CheckpointObjectMember -Owner $item -Name 'production_file_count').Value ` + -TestFileCount (Get-CheckpointObjectMember -Owner $item -Name 'test_file_count').Value ` + -ExecutionContext ([string](Get-CheckpointObjectMember -Owner $item -Name 'execution_context').Value) ` + -CrossCutting (Get-CheckpointObjectMember -Owner $item -Name 'cross_cutting').Value ` + -RootPersona (Get-CheckpointObjectMember -Owner $item -Name 'root_persona').Value + } catch [System.ArgumentException] { + $errors.Add("$prefix has invalid routing inputs: $($_.Exception.Message)") + continue + } + + # U6.T11: every resolver-reproduced key must match the resolver output. + $errors.AddRange([string[]]@(Get-CodexTopologyResolvedKeyError -Receipt $item -Prefix $prefix -Expected $expected)) + } + + return $errors.ToArray() +} + +# Only the family entry point is exported; the input-type and resolved-key helpers +# stay private so no consumer can skip the ordered per-receipt walk. +Export-ModuleMember -Function Get-OrchestratorStateCodexTopologyReceiptError diff --git a/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 index 291c3c9d6..31bccb21d 100644 --- a/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 @@ -1,37 +1,68 @@ <# .SYNOPSIS - Portable completion-gate presence checks for the orchestrator-state checkpoint. + Complete-parity completion-gate validation for the orchestrator-state checkpoint. .DESCRIPTION - Provides the destination-runtime PowerShell mirror of the completion-gate - presence checks the pushed-down validate-orchestrator-output hook needs when the - authoritative Python validator (`scripts/dev_tools`) is not importable. It - reuses the shared load, field-accessor, and base-presence primitives from the - sibling `OrchestratorState.psm1` and imports `.claude/lib/model-routing/ModelRouting.psm1` - so per-receipt model formulas are available where practical. - - The single public function `Test-OrchestratorStateCompletionReadiness` fails - closed on a missing checkpoint file, invalid JSON, or an invalid base shape, then - applies the model-routing "required once delegated" existence gate - the - delegated-agent set (derived from `delegation_receipts[].agent_name` plus a - delegating `next_step`) must be a subset of `model_routing_receipts[].agent` - - mirroring `scripts/dev_tools/_orchestrator_state_model_routing_gate.py`. Deep - per-receipt routing-contract correctness that requires full Python authority is a - documented Non-Goal for the portable path; the gate performs the presence-level - existence check and reports missing receipts with error text containing the - literal token `model_routing_receipts`, so the completion hook maps a failure to - its `MODEL_ROUTING_BLOCKED:` block reason. The Python validator remains - authoritative; this module is the fallback mirror only. + Provides the destination-runtime PowerShell implementation of the completion + validation the pushed-down validate-orchestrator-output hook performs. As of + issue #475 this is a COMPLETE-PARITY port of the Python validator's call + surface `orchestrator-state --require-complete --require-model-routing`, + measured row by row against the parity inventory. It is no longer a + presence-level subset and no longer a fallback for an importable Python + validator: the portable path is the only path. + + `Test-OrchestratorStateCompletionReadiness` composes, in the Python + reference's order: + + U1 the loader contract (missing file, invalid JSON, non-object root), + fail-closed, from `Get-OrchestratorStateCheckpoint` + U2-U6 the whole unconditional block, from + `Get-OrchestratorStateUnconditionalError` + C1,C2 completion step statuses and blocked_reason + C3,C4 the route-gated pr_gate and ci_gate contracts + C5 mandatory route phases + C6 the routing contract, from `OrchestratorStateRoutingContract.psm1` + C7 the preparation terminal contract + M1 model-routing receipt required once delegated + M2 a complexity assessment for every matched receipt phase + M3 per-entry re-validation of the U6.C and U6.M families + + PD-2 SINGLE EMISSION (declared divergence). The Python reference emits the + U6.C and U6.M per-entry errors TWICE for this flag pair: once in the + unconditional block and again inside the model-routing gate's M3 re-run. This + port emits each error string exactly once. The M3 reuse requirement is + satisfied by INVOKING the same per-entry validator implementation + (`Get-OrchestratorStateComplexityAssessmentError` and + `Get-OrchestratorStateModelRoutingReceiptError` from + `OrchestratorStateModelReceipts.psm1`) inside the gate, exactly as the Python + gate reuses its validators, and then adding only strings the accumulated + result does not already carry. Reuse is therefore real, and duplication is + not. The divergence is deliberate: a hook that counted errors would behave + differently against a duplicating validator. + + The `MODEL_ROUTING_BLOCKED` routing guarantee is preserved: a missing routing + 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. #> Set-StrictMode -Version Latest -# Import the sibling shared module and the portable model-routing module, 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 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 $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 + +# 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. +$script:COMPLEXITY_ASSESSMENTS_KEY = 'complexity_assessments' # The subagent types delegated via the Agent tool that can be named by a delegating # next_step. Pinned to _DELEGATING_AGENTS in @@ -182,32 +213,173 @@ function Get-OrchestratorStateModelRoutingGateError { $receiptAgents = @(Get-OrchestratorStateRoutingReceiptAgent -State $State) - # Existence invariant: the routing-receipt agent set must be a superset of the - # delegated-agent set. Report each delegated agent with no receipt, sorted for - # deterministic error ordering. + # M1: the routing-receipt agent set must be a superset of the delegated-agent + # set. Report each delegated agent with no receipt, sorted for deterministic + # error ordering. $missing = $delegated | Where-Object { $receiptAgents -notcontains $_ } | Sort-Object foreach ($agent in $missing) { $errors.Add("Checkpoint model_routing_receipts is missing a receipt for delegated agent: $agent.") } + # M2: every phase named by a receipt that MATCHED a delegated agent must carry + # a complexity assessment. Only matched receipts impose the requirement, so an + # unrelated receipt cannot force an assessment. + $matchedPhases = @(Get-OrchestratorStateMatchedReceiptPhase -State $State -DelegatedAgent $delegated) + $assessedPhases = @(Get-OrchestratorStateAssessedPhase -State $State) + $unassessed = $matchedPhases | Where-Object { $assessedPhases -cnotcontains $_ } | Sort-Object + foreach ($phase in $unassessed) { + $errors.Add("Checkpoint complexity_assessments is missing an entry for phase $phase referenced by a model_routing_receipts entry.") + } + + # M3: re-validate the U6.M and U6.C families by INVOKING the same per-entry + # validator implementation the unconditional block uses, exactly as the Python + # gate reuses its validators. Both calls are key-gated so an absent key does + # not emit a spurious "must be a list when present" error. Emission is left to + # the caller, which applies the PD-2 single-emission rule. + $routingField = Get-CheckpointObjectMember -Owner $State -Name $script:MODEL_ROUTING_RECEIPTS_KEY + if ($routingField.Present) { + $errors.AddRange([string[]]@(Get-OrchestratorStateModelRoutingReceiptError -Value $routingField.Value)) + } + $complexityField = Get-CheckpointObjectMember -Owner $State -Name $script:COMPLEXITY_ASSESSMENTS_KEY + if ($complexityField.Present) { + $errors.AddRange([string[]]@(Get-OrchestratorStateComplexityAssessmentError -Value $complexityField.Value)) + } + return $errors.ToArray() } +function Get-OrchestratorStateMatchedReceiptPhase { + <# + .SYNOPSIS + Collect the phases named by routing receipts that matched a delegated agent. + .DESCRIPTION + Private helper mirroring the matched-phase half of + ``_routing_receipt_agents_and_matched_phases``. Only a receipt whose + ``agent`` is in the delegated set contributes its ``phase``, so an + unrelated receipt cannot force a complexity assessment. + .PARAMETER State + The parsed checkpoint object. + .PARAMETER DelegatedAgent + The delegated-agent names. + .OUTPUTS + System.String[] - the matched phases, rendered with Python str() semantics + so a non-string phase still yields a stable, comparable key. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]] $DelegatedAgent + ) + + $phases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $receipts = (Get-CheckpointObjectMember -Owner $State -Name $script:MODEL_ROUTING_RECEIPTS_KEY).Value + if (-not (Test-CheckpointListValue -Value $receipts)) { return [string[]]@($phases) } + + foreach ($receipt in @($receipts)) { + if (-not (Test-CheckpointObjectValue -Value $receipt)) { continue } + $agent = (Get-CheckpointObjectMember -Owner $receipt -Name 'agent').Value + if (($agent -is [string]) -and ($DelegatedAgent -ccontains [string]$agent)) { + $phase = (Get-CheckpointObjectMember -Owner $receipt -Name 'phase').Value + [void]$phases.Add((ConvertTo-PythonDisplayText -Value $phase)) + } + } + return [string[]]@($phases) +} + +function Get-OrchestratorStateAssessedPhase { + <# + .SYNOPSIS + Collect the phases that carry a complexity-assessment entry. + .DESCRIPTION + Private helper mirroring ``_assessed_phases``. Every well-formed + assessment object contributes its ``phase`` value, including a null one, + so a receipt phase paired with an assessment is not reported missing. + .PARAMETER State + The parsed checkpoint object. + .OUTPUTS + System.String[] - the assessed phases, rendered with Python str() + semantics to match the matched-phase keys. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State + ) + + $phases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $assessments = (Get-CheckpointObjectMember -Owner $State -Name $script:COMPLEXITY_ASSESSMENTS_KEY).Value + if (-not (Test-CheckpointListValue -Value $assessments)) { return [string[]]@($phases) } + + foreach ($assessment in @($assessments)) { + if (-not (Test-CheckpointObjectValue -Value $assessment)) { continue } + $phase = (Get-CheckpointObjectMember -Owner $assessment -Name 'phase').Value + [void]$phases.Add((ConvertTo-PythonDisplayText -Value $phase)) + } + return [string[]]@($phases) +} + +function Add-OrchestratorStateErrorOnce { + <# + .SYNOPSIS + Append error strings the accumulated list does not already carry. + .DESCRIPTION + Private helper implementing the PD-2 single-emission rule. The M3 leg + deliberately re-invokes the U6.C and U6.M per-entry validators, which the + unconditional block already ran, so its output overlaps. Appending only + the strings not already present keeps the reuse real while emitting each + error exactly once, the declared divergence from the Python reference's + duplicate emission. + .PARAMETER Accumulated + The accumulated error list, appended in place. + .PARAMETER Candidate + The candidate error strings. + .OUTPUTS + None. The Accumulated list is appended in place. + #> + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] $Accumulated, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]] $Candidate + ) + + foreach ($item in $Candidate) { + if (-not $Accumulated.Contains($item)) { $Accumulated.Add($item) } + } +} + function Test-OrchestratorStateCompletionReadiness { <# .SYNOPSIS - Validate a checkpoint satisfies the portable completion-gate presence checks. + Validate a checkpoint against the complete-parity completion contract. .DESCRIPTION - Public entry point used by the pushed-down validate-orchestrator-output hook - when the authoritative Python validator is not importable. Loads the - checkpoint (fail-closed on missing file / invalid JSON / invalid base shape), - runs the base-presence check (required keys, step-status validity, - blocked_reason validity), then applies the model-routing required-once- - delegated existence gate. Returns a hashtable compatible with the hook's - invoker contract: ExitCode is 1 whenever any error is present, and Output - carries the newline-joined error text (empty on success). A missing routing - receipt yields error text containing ``model_routing_receipts`` so the hook - surfaces it under ``MODEL_ROUTING_BLOCKED:``. + Public entry point used by the pushed-down validate-orchestrator-output + hook. Loads the checkpoint (fail-closed on missing file, invalid JSON, or + non-object root), then runs the whole unconditional block, the C family, + and the M family in the Python reference's order. Returns a hashtable + compatible with the hook's invoker contract: ExitCode is 1 whenever any + error is present, and Output carries the newline-joined error text (empty + on success). + + PD-2 single emission applies to the M3 leg only: the per-entry U6.C and + U6.M validators are invoked again there, as the Python gate does, but a + string already emitted by the unconditional block is not repeated. Every + other check contributes its errors directly. + + A missing routing receipt yields error text containing + ``model_routing_receipts`` so the hook surfaces it under + ``MODEL_ROUTING_BLOCKED:``. .PARAMETER CheckpointPath The path to the orchestrator-state checkpoint JSON file. .OUTPUTS @@ -220,18 +392,35 @@ function Test-OrchestratorStateCompletionReadiness { [string] $CheckpointPath ) - # Fail closed when the checkpoint cannot be loaded: the load error is the whole - # output and ExitCode is 1. + # U1: fail closed when the checkpoint cannot be loaded. The load error is the + # whole output and ExitCode is 1. $loaded = Get-OrchestratorStateCheckpoint -CheckpointPath $CheckpointPath if (-not $loaded.Ok) { return @{ ExitCode = 1; Output = $loaded.Error } } + $state = $loaded.State - # Accumulate base-presence errors and existence-gate errors; any error yields a - # non-zero ExitCode so the completion hook blocks DONE. $errors = [System.Collections.Generic.List[string]]::new() - $errors.AddRange([string[]]@(Get-OrchestratorStateBasePresenceError -State $loaded.State)) - $errors.AddRange([string[]]@(Get-OrchestratorStateModelRoutingGateError -State $loaded.State)) + + # U2 through U6: the whole unconditional block, in reference order. + $errors.AddRange([string[]]@(Get-OrchestratorStateUnconditionalError -State $state)) + + # C family, in the reference's require_complete order: step statuses, + # blocked_reason, pr_gate, ci_gate, phase completeness, routing contract, and + # the preparation terminal contract. + $errors.AddRange([string[]]@(Get-OrchestratorStateCompletionStepStatusError -State $state)) + $errors.AddRange([string[]]@(Get-OrchestratorStateCompletionBlockedReasonError -State $state)) + $errors.AddRange([string[]]@(Get-OrchestratorStateCompletionPrGateError -State $state)) + $errors.AddRange([string[]]@(Get-OrchestratorStateCompletionCiGateError -State $state)) + $errors.AddRange([string[]]@(Get-OrchestratorStatePhaseCompletenessError -State $state)) + $errors.AddRange([string[]]@(Get-OrchestratorStateRoutingContractError -State $state)) + $errors.AddRange([string[]]@(Get-OrchestratorStatePreparationTerminalError -State $state)) + + # M family. The gate's M3 leg re-invokes the U6.C and U6.M validators, so its + # output is merged under the PD-2 single-emission rule rather than appended + # wholesale. + Add-OrchestratorStateErrorOnce -Accumulated $errors ` + -Candidate ([string[]]@(Get-OrchestratorStateModelRoutingGateError -State $state)) if ($errors.Count -gt 0) { return @{ ExitCode = 1; Output = ($errors -join [System.Environment]::NewLine) } diff --git a/.claude/lib/orchestrator-state/OrchestratorStateCompletionChecks.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateCompletionChecks.psm1 new file mode 100644 index 000000000..eec15a287 --- /dev/null +++ b/.claude/lib/orchestrator-state/OrchestratorStateCompletionChecks.psm1 @@ -0,0 +1,416 @@ +<# +.SYNOPSIS + Portable completion-gate checks C1, C2, C3, C4, C5, and C7. + +.DESCRIPTION + Destination-runtime PowerShell port of the `--require-complete` checks the + authoritative Python validator performs, excluding the routing contract (C6), + which lives in the sibling `OrchestratorStateRoutingContract.psm1`. The rows + ported here are: + + C1.1 completion-blocking step statuses, full five-value blocking set + C2.1 blocked_reason must be absent, null, or the literal `none` + C3.1 pr_gate must be an object (route-gated) + C3.2 pr_gate missing required fields (route-gated) + C4.1 ci_gate must be an object (route-gated) + C4.2 ci_gate missing required fields (route-gated) + C4.3 ci_gate.conclusion must be success (route-gated) + C4.4 ci_gate.head_sha must match pr_gate.head_sha (route-gated) + C5.1 mandatory route phases, from a static map + C7.1 preparation terminal next_step (value-gated, repr rendering) + C7.2 preparation terminal step statuses (value-gated, repr rendering) + + Route gating for C3 and C4 is resolved through + `OrchestratorStateRoutingMatrix.psm1`, implementing deviation PD-1: the gate + decision reads pinned constants and never opens + `config/orchestration-routing.json`. The gates are deliberately asymmetric, + matching the Python reference: the PR gate applies only when a route sets + `requires_pr_gate: true`, while the CI gate applies unless a route explicitly + sets `requires_ci_gate: false`. + + C5's mandatory-phase map is static in the Python reference too, so it needs no + matrix lookup. C7 renders both sides with Python `repr()` semantics. + + Every function is pure: it reads no file, starts no process, and never mutates + its input. +#> + +Set-StrictMode -Version Latest + +# 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 + +# The six step-status keys, in report order. +$script:STEP_STATUS_KEYS = @( + 'step5_status', + 'step6_status', + 'step7_status', + 'step8_status', + 'step9_status', + 'step10_status' +) + +# Step statuses that must never appear in a checkpoint written as DONE. The +# documented S9 success value `passed` is deliberately absent: it records CI green +# and must not block completion. Pinned to COMPLETION_BLOCKING_STEP_STATUS in +# scripts/dev_tools/_orchestrator_state_step_status.py. +$script:COMPLETION_BLOCKING_STEP_STATUS = @( + 'pending', + 'blocked', + 'failed_remediation_required', + 'blocked_ci_loop_limit', + 'blocked_remediation_loop_limit' +) + +# The gate object key sets, in the order the error messages join them. +$script:PR_GATE_KEYS = @('pr_number', 'pr_url', 'head_branch', 'head_sha') +$script:CI_GATE_KEYS = @('conclusion', 'head_sha', 'verified_at') + +# The mandatory canonical phases per route. Static in the Python reference, so no +# matrix lookup is involved; a route absent from this map imposes no requirement. +$script:MANDATORY_ROUTE_PHASES = @{ + small = @('S3_promotion', 'S4_atomic_planning') + preparation = @('S3_promotion', 'S4_atomic_planning') +} + +# The preparation-route terminal contract: the exact required next_step and the +# six step keys that must read not-applicable. +$script:PREPARATION_ROUTE_ID = 'preparation' +$script:PREPARATION_EXPECTED_NEXT_STEP = 'S5_atomic_execution' +$script:PREPARATION_NOT_APPLICABLE = 'not-applicable' + + +function Get-MissingGateKey { + <# + .SYNOPSIS + Return the gate keys absent or blank in a gate object. + .DESCRIPTION + Private helper mirroring _missing_pr_gate_keys / _missing_object_keys. A + non-object value reports every key as missing; a present key holding null + or a blank string is also missing, so a placeholder cannot satisfy a gate. + .PARAMETER Value + The candidate gate value from the checkpoint. May be $null. + .PARAMETER GateKey + The required key names, in message order. + .OUTPUTS + System.String[] - the missing key names, in the given order. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory = $true)] + [string[]] $GateKey + ) + + if (-not (Test-CheckpointObjectValue -Value $Value)) { return [string[]]@($GateKey) } + + $missing = [System.Collections.Generic.List[string]]::new() + foreach ($key in $GateKey) { + $item = (Get-CheckpointObjectMember -Owner $Value -Name $key).Value + if ($null -eq $item -or (($item -is [string]) -and [string]::IsNullOrWhiteSpace([string]$item))) { + $missing.Add($key) + } + } + return $missing.ToArray() +} + +function Get-OrchestratorStateCompletionStepStatusError { + <# + .SYNOPSIS + Return the completion-blocking step-status errors (inventory row C1.1). + .DESCRIPTION + Mirrors collect_completion_blocking_step_errors. One error per step key + whose recorded value is in the five-value blocking set, in step-key order. + The check is applied across all six step keys because the failure values + are per-key-valid only. + .PARAMETER State + The parsed checkpoint object. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # Report in step-key order so the operator reads failures in pipeline order. + foreach ($key in $script:STEP_STATUS_KEYS) { + $value = (Get-CheckpointObjectMember -Owner $State -Name $key).Value + if (($value -is [string]) -and ($script:COMPLETION_BLOCKING_STEP_STATUS -ccontains [string]$value)) { + $errors.Add("Checkpoint completion validation failed: $key is $value.") + } + } + + return $errors.ToArray() +} + +function Get-OrchestratorStateCompletionBlockedReasonError { + <# + .SYNOPSIS + Return the completion blocked_reason error (inventory row C2.1). + .DESCRIPTION + Mirrors the `state.get("blocked_reason") not in {None, "none"}` guard. + Absent, null, and the literal `none` all satisfy the gate; every other + recorded reason blocks completion. The message quotes `none` with + backticks, exactly as the Python reference does. + .PARAMETER State + The parsed checkpoint object. + .OUTPUTS + System.String[] - zero or one error string. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + $value = (Get-CheckpointObjectMember -Owner $State -Name 'blocked_reason').Value + if ($null -ne $value -and -not (($value -is [string]) -and ([string]$value -ceq 'none'))) { + $errors.Add('Checkpoint completion validation failed: blocked_reason is not `none`.') + } + + return $errors.ToArray() +} + +function Get-OrchestratorStateCompletionPrGateError { + <# + .SYNOPSIS + Return the completion PR-gate errors (inventory rows C3.1-C3.2). + .DESCRIPTION + Mirrors validate_completion_pr_gate. The gate applies only to routes whose + pinned `requires_pr_gate` is true; every other route contributes no + pr_gate errors. A non-object pr_gate reports the object-shape error alone, + and an object with absent or blank required fields names them. + .PARAMETER State + The parsed checkpoint object. + .PARAMETER RoutingMatrix + Optional matrix override forwarded to the route lookup. + .OUTPUTS + System.String[] - zero or one error string. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [hashtable] $RoutingMatrix = $null + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + $routeId = Get-OrchestratorStateSelectedRouteId -State $State + if (-not (Test-OrchestratorStateRouteRequiresPrGate -RouteId $routeId -RoutingMatrix $RoutingMatrix)) { + return $errors.ToArray() + } + + $prGate = (Get-CheckpointObjectMember -Owner $State -Name 'pr_gate').Value + $missing = @(Get-MissingGateKey -Value $prGate -GateKey $script:PR_GATE_KEYS) + + # A non-object pr_gate reports only the shape error; the field list would be + # every key and adds nothing. + if (-not (Test-CheckpointObjectValue -Value $prGate)) { + $errors.Add("Checkpoint completion validation failed: pr_gate must be an object with keys: $($script:PR_GATE_KEYS -join ', ').") + return $errors.ToArray() + } + if ($missing.Count -gt 0) { + $errors.Add("Checkpoint completion validation failed: pr_gate missing required fields: $($missing -join ', ').") + } + + return $errors.ToArray() +} + +function Get-OrchestratorStateCompletionCiGateError { + <# + .SYNOPSIS + Return the completion CI-gate errors (inventory rows C4.1-C4.4). + .DESCRIPTION + Mirrors _validate_completion_ci_gate plus its route gate. The gate applies + unless the route's pinned `requires_ci_gate` is exactly false, so an + absent flag keeps the gate on. A non-object ci_gate reports the shape + error alone; otherwise the missing-field, success-conclusion, and + head_sha-match rules each contribute independently. + .PARAMETER State + The parsed checkpoint object. + .PARAMETER RoutingMatrix + Optional matrix override forwarded to the route lookup. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [hashtable] $RoutingMatrix = $null + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + $routeId = Get-OrchestratorStateSelectedRouteId -State $State + if (-not (Test-OrchestratorStateRouteRequiresCiGate -RouteId $routeId -RoutingMatrix $RoutingMatrix)) { + return $errors.ToArray() + } + + $ciGate = (Get-CheckpointObjectMember -Owner $State -Name 'ci_gate').Value + $missing = @(Get-MissingGateKey -Value $ciGate -GateKey $script:CI_GATE_KEYS) + + if (-not (Test-CheckpointObjectValue -Value $ciGate)) { + $errors.Add("Checkpoint completion validation failed: ci_gate must be an object with keys: $($script:CI_GATE_KEYS -join ', ').") + return $errors.ToArray() + } + if ($missing.Count -gt 0) { + $errors.Add("Checkpoint completion validation failed: ci_gate missing required fields: $($missing -join ', ').") + } + + $conclusion = (Get-CheckpointObjectMember -Owner $ciGate -Name 'conclusion').Value + if (-not (Test-PythonValueEqual -Actual $conclusion -Expected 'success')) { + $errors.Add('Checkpoint completion validation failed: ci_gate.conclusion must be success.') + } + + # The head_sha match runs only when a pr_gate object records a non-null + # head_sha; without one there is nothing to match against. + $prGate = (Get-CheckpointObjectMember -Owner $State -Name 'pr_gate').Value + $prHeadSha = $null + if (Test-CheckpointObjectValue -Value $prGate) { + $prHeadSha = (Get-CheckpointObjectMember -Owner $prGate -Name 'head_sha').Value + } + if ($null -ne $prHeadSha) { + $ciHeadSha = (Get-CheckpointObjectMember -Owner $ciGate -Name 'head_sha').Value + if (-not (Test-PythonValueEqual -Actual $ciHeadSha -Expected $prHeadSha)) { + $errors.Add('Checkpoint completion validation failed: ci_gate.head_sha must match pr_gate.head_sha.') + } + } + + return $errors.ToArray() +} + +function Get-OrchestratorStatePhaseCompletenessError { + <# + .SYNOPSIS + Return the mandatory-phase errors for the selected route (row C5.1). + .DESCRIPTION + Mirrors validate_phase_completeness. The mandatory-phase set is read from + a static map, not from the routing matrix, so no matrix parameter exists. + A route absent from the map, or an unusable route id, imposes no + requirement. A `completed_steps` value that is not a list of non-blank + strings is treated as recording no completed phases. + .PARAMETER State + The parsed checkpoint object. + .OUTPUTS + System.String[] - one error per missing mandatory phase. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + $routeId = Get-OrchestratorStateSelectedRouteId -State $State + if ($null -eq $routeId -or -not $script:MANDATORY_ROUTE_PHASES.ContainsKey($routeId)) { + return $errors.ToArray() + } + + # A malformed completed_steps records no phases, so every mandatory phase is + # reported missing rather than the malformed shape being reported separately. + $completed = (Get-CheckpointObjectMember -Owner $State -Name 'completed_steps').Value + $present = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + if (Test-CheckpointListValue -Value $completed) { + $wellFormed = $true + foreach ($item in @($completed)) { + if (-not ($item -is [string]) -or [string]::IsNullOrWhiteSpace([string]$item)) { + $wellFormed = $false + break + } + } + if ($wellFormed) { + foreach ($item in @($completed)) { [void]$present.Add([string]$item) } + } + } + + foreach ($phase in $script:MANDATORY_ROUTE_PHASES[$routeId]) { + if (-not $present.Contains($phase)) { + $errors.Add("Checkpoint completion validation failed: route $routeId is missing mandatory phase $phase.") + } + } + + return $errors.ToArray() +} + +function Get-OrchestratorStatePreparationTerminalError { + <# + .SYNOPSIS + Return the preparation terminal-contract errors (rows C7.1-C7.2). + .DESCRIPTION + Mirrors validate_preparation_terminal_contract. The check is value-gated + on the RAW route value being exactly the string `preparation`, so a + checkpoint on any other route contributes nothing. Both messages render + the offending value with Python repr() semantics. + .PARAMETER State + The parsed checkpoint object. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + $routeValue = Get-OrchestratorStateRawRouteValue -State $State + if (-not (Test-PythonValueEqual -Actual $routeValue -Expected $script:PREPARATION_ROUTE_ID)) { + return $errors.ToArray() + } + + $nextStep = (Get-CheckpointObjectMember -Owner $State -Name 'next_step').Value + if (-not (Test-PythonValueEqual -Actual $nextStep -Expected $script:PREPARATION_EXPECTED_NEXT_STEP)) { + $expectedText = ConvertTo-PythonReprText -Value $script:PREPARATION_EXPECTED_NEXT_STEP + $errors.Add("Preparation checkpoint next_step must be $expectedText, found $(ConvertTo-PythonReprText -Value $nextStep).") + } + + # A preparation run performs no execution steps, so all six step keys must + # read not-applicable; each deviation is reported with its own key. + foreach ($key in $script:STEP_STATUS_KEYS) { + $value = (Get-CheckpointObjectMember -Owner $State -Name $key).Value + if (-not (Test-PythonValueEqual -Actual $value -Expected $script:PREPARATION_NOT_APPLICABLE)) { + $errors.Add("Preparation checkpoint $key must be 'not-applicable', found $(ConvertTo-PythonReprText -Value $value).") + } + } + + return $errors.ToArray() +} + +# Each check family is exported individually so the completion entry point can +# compose them in the Python reference's order; the gate-key helper stays private. +Export-ModuleMember -Function ` + Get-OrchestratorStateCompletionStepStatusError, ` + Get-OrchestratorStateCompletionBlockedReasonError, ` + Get-OrchestratorStateCompletionPrGateError, ` + Get-OrchestratorStateCompletionCiGateError, ` + Get-OrchestratorStatePhaseCompletenessError, ` + Get-OrchestratorStatePreparationTerminalError diff --git a/.claude/lib/orchestrator-state/OrchestratorStateModelReceipts.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateModelReceipts.psm1 new file mode 100644 index 000000000..9a61d3c56 --- /dev/null +++ b/.claude/lib/orchestrator-state/OrchestratorStateModelReceipts.psm1 @@ -0,0 +1,366 @@ +<# +.SYNOPSIS + Portable complexity-assessment and model-routing-receipt per-entry checks. + +.DESCRIPTION + Destination-runtime PowerShell port of the key-gated per-entry validators the + authoritative Python validator runs in its unconditional block. The rows + ported here are the parity-inventory families U6.C (complexity_assessments, + rows U6.C1-U6.C7) and U6.M (model_routing_receipts, rows U6.M1-U6.M6), + matching the error-string templates in + `scripts/dev_tools/_orchestrator_state_complexity.py` and + `scripts/dev_tools/_orchestrator_state_model_routing.py`. + + SINGLE-IMPLEMENTATION RULE. Row U6.C5 recomputes the floor by calling + `Get-ComplexityFloor` and row U6.M4 resolves the expected model by calling + `Resolve-DelegationModel`, both from `.claude/lib/model-routing/ModelRouting.psm1`. + Neither formula is re-implemented here, mirroring the Python gate's own reuse + constraint. A future change to either formula must be made in that one module. + + 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. +#> + +Set-StrictMode -Version Latest + +# 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 +$script:ModelRoutingModulePath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath (Join-Path -Path 'model-routing' -ChildPath 'ModelRouting.psm1') +Import-Module $script:ModelRoutingModulePath -Force + +# 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 +# inside ModelRouting.psm1. This is the enum and its ordering only; the floor and +# model formulas themselves are never duplicated here. +$script:BAND_ORDER = @('C1', 'C2', 'C3', 'C4') + +# The model tier removed from consideration under the disabled policy, the tier a +# disabled-mode fable cell clamps down to, and the policy literal itself. +$script:FABLE_MODEL = 'fable' +$script:DISABLED_CLAMP_MODEL = 'opus' +$script:DISABLED_POLICY = 'disabled' + + +function Get-CheckpointStringList { + <# + .SYNOPSIS + Return a value as a string list only when it has that exact shape. + .DESCRIPTION + Private helper mirroring _string_list in the Python complexity module. A + non-list value, or a list containing a non-string element, has no string + list form; an empty list does, and yields an empty result. + .PARAMETER Value + The deserialized JSON value to inspect. May be $null. + .OUTPUTS + System.Collections.Hashtable with keys Ok (bool) and Value (string[]). + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + if (-not (Test-CheckpointListValue -Value $Value)) { + return @{ Ok = $false; Value = [string[]]@() } + } + + # A single non-string element disqualifies the whole list, matching Python's + # all(isinstance(item, str)) guard. + foreach ($item in $Value) { + if (-not ($item -is [string])) { + return @{ Ok = $false; Value = [string[]]@() } + } + } + + return @{ Ok = $true; Value = [string[]]@($Value) } +} + +function Get-ComplexityAssessmentEntryError { + <# + .SYNOPSIS + Return one complexity assessment's errors (rows U6.C3-U6.C7). + .DESCRIPTION + Private helper mirroring _validate_one_assessment: band enum membership, + a recomputable signals list, floor equality against Get-ComplexityFloor, + the band-at-or-above-floor lower bound, and a non-empty rationale. The + floor is never recomputed inline; the shared formula is called. + .PARAMETER Index + The assessment's zero-based position, used for error context. + .PARAMETER Assessment + The deserialized assessment object. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [int] $Index, + + [Parameter(Mandatory = $true)] + [psobject] $Assessment + ) + + $errors = [System.Collections.Generic.List[string]]::new() + $band = (Get-CheckpointObjectMember -Owner $Assessment -Name 'band').Value + $floor = (Get-CheckpointObjectMember -Owner $Assessment -Name 'floor').Value + $signalsPresent = (Get-CheckpointObjectMember -Owner $Assessment -Name 'signals_present').Value + $rationale = (Get-CheckpointObjectMember -Owner $Assessment -Name 'rationale').Value + + $bandValid = ($band -is [string]) -and ($script:BAND_ORDER -ccontains [string]$band) + $floorValid = ($floor -is [string]) -and ($script:BAND_ORDER -ccontains [string]$floor) + + # U6.C3: band must be within the permitted enum. + if (-not $bandValid) { + $errors.Add("Checkpoint complexity_assessments #$Index band must be one of C1, C2, C3, C4; got: $(ConvertTo-PythonDisplayText -Value $band).") + } + + # U6.C4 / U6.C5: the floor can only be recomputed from a list of strings, so a + # malformed signals list reports itself and suppresses the equality check. + $signalList = Get-CheckpointStringList -Value $signalsPresent + if (-not $signalList.Ok) { + $errors.Add("Checkpoint complexity_assessments #$Index signals_present must be a list of strings.") + } else { + $expectedFloor = Get-ComplexityFloor -SignalsPresent $signalList.Value + if (-not ($floor -is [string]) -or ([string]$floor -cne [string]$expectedFloor)) { + $errors.Add("Checkpoint complexity_assessments #$Index floor $(ConvertTo-PythonDisplayText -Value $floor) does not equal compute_complexity_floor(signals_present) $expectedFloor.") + } + } + + # U6.C6: the band-at-or-above-floor lower bound. Both values must be valid + # bands to compare, so a prior enum error suppresses a spurious ordering error. + if ($bandValid -and $floorValid -and + ($script:BAND_ORDER.IndexOf([string]$band) -lt $script:BAND_ORDER.IndexOf([string]$floor))) { + $errors.Add("Checkpoint complexity_assessments #$Index band $band is below its floor $floor.") + } + + # U6.C7: rationale must be a non-empty string. + if (-not ($rationale -is [string]) -or [string]::IsNullOrWhiteSpace([string]$rationale)) { + $errors.Add("Checkpoint complexity_assessments #$Index rationale must be a non-empty string.") + } + + return $errors.ToArray() +} + +function Get-OrchestratorStateComplexityAssessmentError { + <# + .SYNOPSIS + Return the complexity_assessments errors (inventory rows U6.C1-U6.C7). + .DESCRIPTION + Public entry mirroring _validate_complexity_assessments. The caller invokes + this only when the key is present, so a non-list value is itself the error. + Each entry is validated independently so callers receive a complete list. + .PARAMETER Value + The raw deserialized value of the checkpoint's complexity_assessments key. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + if (-not (Test-CheckpointListValue -Value $Value)) { + $errors.Add('Checkpoint complexity_assessments must be a list when present.') + return $errors.ToArray() + } + + # Walk every entry position so each malformed assessment is reported with its + # own index rather than stopping at the first one. + $index = 0 + foreach ($assessment in @($Value)) { + if (-not (Test-CheckpointObjectValue -Value $assessment)) { + $errors.Add("Checkpoint complexity_assessments #$index must be an object.") + $index++ + continue + } + $errors.AddRange([string[]]@(Get-ComplexityAssessmentEntryError -Index $index -Assessment $assessment)) + $index++ + } + + return $errors.ToArray() +} + +function Get-ModelRoutingDisabledClampError { + <# + .SYNOPSIS + Return one receipt's disabled-mode clamp errors (rows U6.M5-U6.M6). + .DESCRIPTION + Private helper mirroring _validate_disabled_clamp. Under the disabled + policy fable is removed from the consideration set, so no receipt may + resolve to fable, and a fable table cell must record the clamp to opus + with clamped_from fable. + .PARAMETER Index + The receipt's zero-based position, used for error context. + .PARAMETER TableModel + The receipt's pre-clamp table_model value. + .PARAMETER ClampedFrom + The receipt's clamped_from value. + .PARAMETER Model + The receipt's post-clamp model value. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [int] $Index, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $TableModel, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $ClampedFrom, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Model + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # U6.M5: no receipt model may be fable under the disabled policy. + if (($Model -is [string]) -and ([string]$Model -ceq $script:FABLE_MODEL)) { + $errors.Add("Checkpoint model_routing_receipts #$Index model must not be fable under fable_policy disabled.") + } + + # U6.M6: a fable table cell must record the clamp provenance; both halves of + # the provenance (clamped_from fable and model opus) are required together. + if (($TableModel -is [string]) -and ([string]$TableModel -ceq $script:FABLE_MODEL)) { + $clampRecorded = ($ClampedFrom -is [string]) -and ([string]$ClampedFrom -ceq $script:FABLE_MODEL) -and + ($Model -is [string]) -and ([string]$Model -ceq $script:DISABLED_CLAMP_MODEL) + if (-not $clampRecorded) { + $errors.Add("Checkpoint model_routing_receipts #$Index table_model fable under fable_policy disabled must record clamped_from fable and model opus.") + } + } + + return $errors.ToArray() +} + +function Get-ModelRoutingReceiptEntryError { + <# + .SYNOPSIS + Return one model-routing receipt's errors (rows U6.M3-U6.M6). + .DESCRIPTION + Private helper mirroring _validate_one_receipt: the band must be a valid + enum member before the resolver can run, the recorded model must equal + Resolve-DelegationModel's result, and the disabled-mode clamp invariants + apply when the session policy removed fable. The model formula is never + re-implemented here; the shared formula is called. + .PARAMETER Index + The receipt's zero-based position, used for error context. + .PARAMETER Receipt + The deserialized receipt object. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [int] $Index, + + [Parameter(Mandatory = $true)] + [psobject] $Receipt + ) + + $errors = [System.Collections.Generic.List[string]]::new() + $agent = (Get-CheckpointObjectMember -Owner $Receipt -Name 'agent').Value + $band = (Get-CheckpointObjectMember -Owner $Receipt -Name 'complexity_band').Value + $fablePolicy = (Get-CheckpointObjectMember -Owner $Receipt -Name 'fable_policy').Value + $tableModel = (Get-CheckpointObjectMember -Owner $Receipt -Name 'table_model').Value + $clampedFrom = (Get-CheckpointObjectMember -Owner $Receipt -Name 'clamped_from').Value + $model = (Get-CheckpointObjectMember -Owner $Receipt -Name 'model').Value + + # U6.M3: an invalid band cannot be resolved, so report it and stop this + # receipt rather than calling the resolver with an out-of-table key. + if (-not ($band -is [string]) -or -not ($script:BAND_ORDER -ccontains [string]$band)) { + $errors.Add("Checkpoint model_routing_receipts #$Index complexity_band must be one of C1, C2, C3, C4; got: $(ConvertTo-PythonDisplayText -Value $band).") + return $errors.ToArray() + } + + # U6.M4: resolve the expected model from the canonical shared formula. Agent + # and policy are rendered through the Python str() renderer because the Python + # reference coerces both with str() before the lookup. + $expected = Resolve-DelegationModel ` + -Agent (ConvertTo-PythonDisplayText -Value $agent) ` + -Band ([string]$band) ` + -FablePolicy (ConvertTo-PythonDisplayText -Value $fablePolicy) + $expectedModel = [string]$expected['model'] + if (-not ($model -is [string]) -or ([string]$model -cne $expectedModel)) { + $errors.Add("Checkpoint model_routing_receipts #$Index model $(ConvertTo-PythonDisplayText -Value $model) does not equal resolve_delegation_model(agent, complexity_band, fable_policy) $expectedModel.") + } + + # The clamp invariants apply only when fable is removed from the consideration + # set for this session. + if (($fablePolicy -is [string]) -and ([string]$fablePolicy -ceq $script:DISABLED_POLICY)) { + $errors.AddRange([string[]]@( + Get-ModelRoutingDisabledClampError -Index $Index -TableModel $tableModel -ClampedFrom $clampedFrom -Model $model + )) + } + + return $errors.ToArray() +} + +function Get-OrchestratorStateModelRoutingReceiptError { + <# + .SYNOPSIS + Return the model_routing_receipts errors (inventory rows U6.M1-U6.M6). + .DESCRIPTION + Public entry mirroring _validate_model_routing_receipts. The caller invokes + this only when the key is present, so a non-list value is itself the error. + Each receipt is validated independently so callers receive a complete list. + .PARAMETER Value + The raw deserialized value of the checkpoint's model_routing_receipts key. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + if (-not (Test-CheckpointListValue -Value $Value)) { + $errors.Add('Checkpoint model_routing_receipts must be a list when present.') + return $errors.ToArray() + } + + # Walk every receipt position so each malformed entry is reported with its own + # index rather than stopping at the first one. + $index = 0 + foreach ($receipt in @($Value)) { + if (-not (Test-CheckpointObjectValue -Value $receipt)) { + $errors.Add("Checkpoint model_routing_receipts #$index must be an object.") + $index++ + continue + } + $errors.AddRange([string[]]@(Get-ModelRoutingReceiptEntryError -Index $index -Receipt $receipt)) + $index++ + } + + return $errors.ToArray() +} + +# Both family entry points are exported for the U-family aggregator and for the +# completion gate's M3 reuse leg, which invokes this same implementation rather +# than re-implementing the per-entry rows. +Export-ModuleMember -Function ` + Get-OrchestratorStateComplexityAssessmentError, ` + Get-OrchestratorStateModelRoutingReceiptError diff --git a/.claude/lib/orchestrator-state/OrchestratorStateReceipts.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateReceipts.psm1 new file mode 100644 index 000000000..ea5a6a4ce --- /dev/null +++ b/.claude/lib/orchestrator-state/OrchestratorStateReceipts.psm1 @@ -0,0 +1,408 @@ +<# +.SYNOPSIS + Portable delegation-receipt, remediation-cycle, and human-interaction checks. + +.DESCRIPTION + Destination-runtime PowerShell port of the unconditional optional-key checks + the authoritative Python validator performs for the completion hook's call. + The rows ported here are the parity-inventory families U5 (delegation_receipts + shape), U6.R (remediation_loop cycle invariants), and U6.H (human_interaction + shape), matching the error-string templates in + `scripts/dev_tools/validate_orchestrator_state.py` and + `scripts/dev_tools/_orchestrator_state_human_interaction.py`. + + U6.H reconciliation (spec Parity Contract): these library checks are ADDITIVE + to the stricter hook-internal `Test-HumanInteractionShape` in + `.claude/hooks/validate-orchestrator-output.ps1`, which blocks a `halt` + response and verifies runbook-file existence. That hook check is retained + unchanged and continues to run alongside these rows; strictness never + decreases. A checkpoint that fails either layer is blocked. + + The shared JSON shape predicates, the absent-versus-null member accessor, the + ordinal key sort, the Python zero-equivalence rule, and the Python + `str()`/`repr()` interpolation renderers live in the sibling helper module + `OrchestratorStateCheckpointValue.psm1`, created under the plan's + pre-authorized split so no parity module exceeds the 500-line file cap. Every + module in this family imports those primitives from that one implementation. + + 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. +#> + +Set-StrictMode -Version Latest + +# 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 + +# The eight keys every list-form delegation receipt must carry. Pinned to +# REQUIRED_RECEIPT_KEYS in scripts/dev_tools/validate_orchestrator_state.py. +$script:REQUIRED_RECEIPT_KEYS = @( + 'step', + 'agent_name', + 'agent_id', + 'skill_source', + 'started_at', + 'completed_at', + 'result_signal', + 'artifact_paths' +) + +# The only two namespaces the object form of delegation_receipts may carry, and +# the three sub-keys the promotion namespace may carry. +$script:AGENT_RECEIPT_NAMESPACE_KEY = 'agents' +$script:PROMOTION_RECEIPT_NAMESPACE_KEY = 'promotion' +$script:PROMOTION_RECEIPT_KEYS = @('potential_entry', 'issue', 'feature_folder') + +# Remediation-cycle constants: the cycles array key, the execution statuses that +# may only be recorded once preflight cleared, and the cleared status literal. +$script:REMEDIATION_CYCLES_KEY = 'cycles' +$script:EXECUTION_STATUSES_REQUIRING_CLEAR_PREFLIGHT = @('in_progress', 'complete', 'failed') +$script:PREFLIGHT_CLEARED_STATUS = 'clear' + +# Human-interaction constants: the requirements list key, the three permitted +# response values, and the response that additionally requires a runbook path. +$script:HUMAN_INTERACTION_REQUIREMENTS_KEY = 'requirements' +$script:HUMAN_INTERACTION_RESPONSE_ENUM = @('scope_change', 'exception', 'halt') +$script:HUMAN_INTERACTION_EXCEPTION_RESPONSE = 'exception' + + +function Get-DelegationReceiptListError { + <# + .SYNOPSIS + Return the list-form delegation-receipt errors (rows U5.1-U5.3). + .DESCRIPTION + Private helper mirroring _validate_list_delegation_receipts. Validates each + receipt independently so callers receive a complete error list. + .PARAMETER Receipts + The deserialized JSON array of receipt objects. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Receipts + ) + + $errors = [System.Collections.Generic.List[string]]::new() + $index = 0 + + # Walk every receipt position so each malformed entry is reported with its own + # index; a non-object entry short-circuits that entry's key checks. + foreach ($receipt in @($Receipts)) { + if (-not (Test-CheckpointObjectValue -Value $receipt)) { + $errors.Add("Checkpoint delegation receipt #$index must be an object.") + $index++ + continue + } + + # Key PRESENCE is the Python test (`key not in receipt`), so a present key + # holding null satisfies the requirement. + $names = @(Get-CheckpointObjectMemberName -Owner $receipt) + foreach ($key in $script:REQUIRED_RECEIPT_KEYS) { + if ($names -notcontains $key) { + $errors.Add("Checkpoint delegation receipt #$index missing key: $key") + } + } + + # artifact_paths may be absent or null, but a present non-null value must + # be a list. + $artifactPaths = (Get-CheckpointObjectMember -Owner $receipt -Name 'artifact_paths').Value + if ($null -ne $artifactPaths -and -not (Test-CheckpointListValue -Value $artifactPaths)) { + $errors.Add("Checkpoint delegation receipt #$index artifact_paths must be a list.") + } + + $index++ + } + + return $errors.ToArray() +} + +function Get-DelegationReceiptNamespaceError { + <# + .SYNOPSIS + Return the object-form delegation-receipt errors (rows U5.4-U5.7). + .DESCRIPTION + Private helper mirroring _validate_namespaced_delegation_receipts. Reports + unsupported top-level namespaces in ordinal order, applies the list-form + checks to the agents namespace, and rejects an unsupported promotion + sub-key or a non-object promotion namespace. + .PARAMETER Receipts + The deserialized JSON object form of delegation_receipts. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $Receipts + ) + + $errors = [System.Collections.Generic.List[string]]::new() + $names = @(Get-CheckpointObjectMemberName -Owner $Receipts) + + # Reject every namespace outside {agents, promotion}, ordered ordinally so the + # error sequence matches Python's sorted() output. + $unsupported = @($names | Where-Object { + $_ -ne $script:AGENT_RECEIPT_NAMESPACE_KEY -and $_ -ne $script:PROMOTION_RECEIPT_NAMESPACE_KEY + }) + foreach ($key in (Get-CheckpointOrdinalSortedName -Name ([string[]]$unsupported))) { + $errors.Add("Checkpoint delegation_receipts object contains unsupported key: $key") + } + + # A present agents namespace must be a list; when it is, the list-form rows + # apply to its entries unchanged. + if ($names -contains $script:AGENT_RECEIPT_NAMESPACE_KEY) { + $agentReceipts = (Get-CheckpointObjectMember -Owner $Receipts -Name $script:AGENT_RECEIPT_NAMESPACE_KEY).Value + if (-not (Test-CheckpointListValue -Value $agentReceipts)) { + $errors.Add('Checkpoint delegation_receipts.agents must be a list.') + } else { + $errors.AddRange([string[]]@(Get-DelegationReceiptListError -Receipts $agentReceipts)) + } + } + + # An absent or null promotion namespace ends the check; a present non-object + # value is malformed and stops further promotion inspection. + $promotion = (Get-CheckpointObjectMember -Owner $Receipts -Name $script:PROMOTION_RECEIPT_NAMESPACE_KEY).Value + if ($null -eq $promotion) { + return $errors.ToArray() + } + if (-not (Test-CheckpointObjectValue -Value $promotion)) { + $errors.Add('Checkpoint delegation_receipts.promotion must be an object namespace.') + return $errors.ToArray() + } + + $promotionNames = @(Get-CheckpointObjectMemberName -Owner $promotion) + $unsupportedPromotion = @($promotionNames | Where-Object { $script:PROMOTION_RECEIPT_KEYS -notcontains $_ }) + foreach ($key in (Get-CheckpointOrdinalSortedName -Name ([string[]]$unsupportedPromotion))) { + $errors.Add("Checkpoint delegation_receipts.promotion contains unsupported key: $key") + } + + return $errors.ToArray() +} + +function Get-OrchestratorStateDelegationReceiptError { + <# + .SYNOPSIS + Return the delegation_receipts errors (inventory rows U5.1-U5.8). + .DESCRIPTION + Public dispatch mirroring the delegation-receipt branch of + validate_orchestrator_state_text: a null or absent value contributes no + errors, a list routes to the legacy list-form checks, an object routes to + the namespaced checks, and any other value is itself the error. + .PARAMETER Value + The raw deserialized value of the checkpoint's delegation_receipts key. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # A null value is the Python `receipts is not None` guard: nothing to check. + if ($null -eq $Value) { return $errors.ToArray() } + + # Route on the deserialized shape: the list branch is the legacy form, the + # object branch is the additive namespace form, anything else is invalid. + if (Test-CheckpointListValue -Value $Value) { + $errors.AddRange([string[]]@(Get-DelegationReceiptListError -Receipts $Value)) + } elseif (Test-CheckpointObjectValue -Value $Value) { + $errors.AddRange([string[]]@(Get-DelegationReceiptNamespaceError -Receipts $Value)) + } else { + $errors.Add('Checkpoint delegation_receipts must be a list or object namespace.') + } + + return $errors.ToArray() +} + +function Get-RemediationCycleError { + <# + .SYNOPSIS + Return one remediation cycle's errors (rows U6.R2-U6.R4). + .DESCRIPTION + Private helper mirroring _validate_remediation_cycle: a non-empty + plan_path, execution only after a cleared preflight, and a satisfied exit + gate only with zero blocking findings. + .PARAMETER Index + The cycle's zero-based position, used for error context. + .PARAMETER Cycle + The deserialized cycle object. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [int] $Index, + + [Parameter(Mandatory = $true)] + [psobject] $Cycle + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # Invariant 1: plan_path must be a non-empty, non-whitespace string. + $planPath = (Get-CheckpointObjectMember -Owner $Cycle -Name 'plan_path').Value + if (-not ($planPath -is [string]) -or [string]::IsNullOrWhiteSpace([string]$planPath)) { + $errors.Add("Checkpoint remediation cycle #$Index plan_path must be a non-empty string.") + } + + # Invariant 2: an execution status in the blocked set requires the nested + # preflight to report exactly the cleared status; a missing or non-object + # preflight cannot satisfy it. + $executionStatus = (Get-CheckpointObjectMember -Owner $Cycle -Name 'execution_status').Value + if ($executionStatus -is [string] -and + ($script:EXECUTION_STATUSES_REQUIRING_CLEAR_PREFLIGHT -contains [string]$executionStatus)) { + $preflight = (Get-CheckpointObjectMember -Owner $Cycle -Name 'preflight').Value + $preflightStatus = (Get-CheckpointObjectMember -Owner $preflight -Name 'final_status').Value + if (-not ($preflightStatus -is [string]) -or ([string]$preflightStatus -ne $script:PREFLIGHT_CLEARED_STATUS)) { + $errors.Add("Checkpoint remediation cycle #$Index execution_status is $executionStatus but preflight.final_status is not 'clear'.") + } + } + + # Invariant 3: a satisfied exit gate requires zero blocking findings. The exit + # flag must be the boolean True (Python `is True`), not merely truthy. + $exitConditionMet = (Get-CheckpointObjectMember -Owner $Cycle -Name 'exit_condition_met').Value + if (($exitConditionMet -is [bool]) -and $exitConditionMet) { + $blockingCount = (Get-CheckpointObjectMember -Owner $Cycle -Name 'blocking_count').Value + if (-not (Test-PythonZeroEquivalent -Value $blockingCount)) { + $errors.Add("Checkpoint remediation cycle #$Index exit_condition_met is true but blocking_count is not 0.") + } + } + + return $errors.ToArray() +} + +function Get-OrchestratorStateRemediationLoopError { + <# + .SYNOPSIS + Return the remediation_loop errors (inventory rows U6.R1-U6.R4). + .DESCRIPTION + Public entry mirroring _validate_remediation_loop. A non-object + remediation_loop, or a cycles value that is not a list, carries no cycles + to validate and deliberately yields ZERO errors, matching the Python + tolerance rather than fabricating a structural error. + .PARAMETER Value + The raw deserialized value of the checkpoint's remediation_loop key. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + if (-not (Test-CheckpointObjectValue -Value $Value)) { return $errors.ToArray() } + $cycles = (Get-CheckpointObjectMember -Owner $Value -Name $script:REMEDIATION_CYCLES_KEY).Value + if (-not (Test-CheckpointListValue -Value $cycles)) { return $errors.ToArray() } + + # Validate each cycle independently so a malformed entry does not mask the + # errors of the cycles that follow it. + $index = 0 + foreach ($cycle in @($cycles)) { + if (-not (Test-CheckpointObjectValue -Value $cycle)) { + $errors.Add("Checkpoint remediation cycle #$index must be an object.") + $index++ + continue + } + $errors.AddRange([string[]]@(Get-RemediationCycleError -Index $index -Cycle $cycle)) + $index++ + } + + return $errors.ToArray() +} + +function Get-OrchestratorStateHumanInteractionError { + <# + .SYNOPSIS + Return the human_interaction errors (inventory rows U6.H1-U6.H5). + .DESCRIPTION + Public entry mirroring _validate_human_interaction: the block must be an + object carrying a requirements list, each requirement must be an object + whose response is within the permitted enum, and an exception response + must carry a non-empty runbook_path. These rows are additive to the + stricter hook-internal Test-HumanInteractionShape described in the module + header; they never relax it. + .PARAMETER Value + The raw deserialized value of the checkpoint's human_interaction key. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # The key was present, so a non-object value is a malformed block rather than + # an absent one, and there is nothing further to inspect. + if (-not (Test-CheckpointObjectValue -Value $Value)) { + $errors.Add('Checkpoint human_interaction must be an object when present.') + return $errors.ToArray() + } + + $requirements = (Get-CheckpointObjectMember -Owner $Value -Name $script:HUMAN_INTERACTION_REQUIREMENTS_KEY).Value + if (-not (Test-CheckpointListValue -Value $requirements)) { + $errors.Add('Checkpoint human_interaction.requirements must be a list.') + return $errors.ToArray() + } + + # Validate each requirement independently; an out-of-enum response stops that + # requirement so the runbook rule is not applied to an unknown response. + $index = 0 + foreach ($requirement in @($requirements)) { + if (-not (Test-CheckpointObjectValue -Value $requirement)) { + $errors.Add("Checkpoint human_interaction.requirements #$index must be an object.") + $index++ + continue + } + + $response = (Get-CheckpointObjectMember -Owner $requirement -Name 'response').Value + if (-not ($response -is [string]) -or ($script:HUMAN_INTERACTION_RESPONSE_ENUM -notcontains [string]$response)) { + $rendered = ConvertTo-PythonDisplayText -Value $response + $errors.Add("Checkpoint human_interaction.requirements #$index response must be one of scope_change, exception, halt; got: $rendered") + $index++ + continue + } + + if ([string]$response -eq $script:HUMAN_INTERACTION_EXCEPTION_RESPONSE) { + $runbookPath = (Get-CheckpointObjectMember -Owner $requirement -Name 'runbook_path').Value + if (-not ($runbookPath -is [string]) -or [string]::IsNullOrWhiteSpace([string]$runbookPath)) { + $errors.Add("Checkpoint human_interaction.requirements #$index response is exception but runbook_path is missing or empty.") + } + } + + $index++ + } + + return $errors.ToArray() +} + +# The three family entry points are exported for the U-family aggregator; the +# shared primitives stay exported from the sibling OrchestratorStateCheckpointValue +# module so there is exactly one implementation of each. +Export-ModuleMember -Function ` + Get-OrchestratorStateDelegationReceiptError, ` + Get-OrchestratorStateRemediationLoopError, ` + Get-OrchestratorStateHumanInteractionError diff --git a/.claude/lib/orchestrator-state/OrchestratorStateRoutingContract.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateRoutingContract.psm1 new file mode 100644 index 000000000..92f8a5823 --- /dev/null +++ b/.claude/lib/orchestrator-state/OrchestratorStateRoutingContract.psm1 @@ -0,0 +1,428 @@ +<# +.SYNOPSIS + Portable routing-contract completion checks (inventory family C6). + +.DESCRIPTION + Destination-runtime PowerShell port of `validate_routing_contract` in + `scripts/dev_tools/_orchestrator_state_routing.py`, covering parity-inventory + rows C6.1 through C6.14: + + C6.1 routing matrix carries no routes object (returns) + C6.2 no route selected (returns) + C6.3 selected route has no routing-matrix entry (returns) + C6.4 required_agents must match the matrix + C6.5 required_skills must match the matrix + C6.6 required_mcp_tools must match the matrix + C6.7 per required agent with no delegation receipt + C6.8 per required skill with no acknowledged receipt + C6.9 per required MCP tool with no successful receipt + C6.10 local_execution_overrides empty-list rules (two message variants) + C6.11 delegation_bypasses empty-list rules (two message variants) + C6.12 lifecycle_operations must be a list when present + C6.13 per non-object lifecycle operation + C6.14 per lifecycle operation that did not use the MCP surface + + The routing matrix is read from `OrchestratorStateRoutingMatrix.psm1`, which + implements deviation PD-1: the constants are pinned and no disk read occurs at + validation time. The optional -RoutingMatrix override mirrors the Python + `routing_matrix` keyword and is what makes row C6.1 reachable. + + Bug-promotion tool substitution (C6.6 and C6.9). The routing matrix records + the feature-type promotion-entry tool `new_potential_entry` in every route's + `required_mcp_tools`. A bug-type promotion genuinely exercises + `new_potential_bug_entry` instead, so a bug-type checkpoint could never + truthfully record a `new_potential_entry` receipt. When, and only when, the + checkpoint's hyphenated `promotion-type` is exactly `"bug"`, each occurrence + is substituted, preserving matrix order and every other tool. The substituted + list drives both the exact-match check and the receipt-presence loop, so the + two never disagree. + + Empty-list semantics (C6.10 and C6.11). The Python rule requires the key to + EXIST as a list: an ABSENT key is not a list and therefore produces the + "must be an empty list at completion" variant. That is deliberately stricter + than the PR-creation-readiness analogue in `OrchestratorState.psm1`, which + tolerates absence; both behaviours are preserved as-is. + + Every function is pure: it reads no file, starts no process, and never mutates + its input. +#> + +Set-StrictMode -Version Latest + +# 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 + +# The promotion-entry MCP tools and the promotion type that triggers substitution. +$script:FEATURE_PROMOTION_ENTRY_TOOL = 'new_potential_entry' +$script:BUG_PROMOTION_ENTRY_TOOL = 'new_potential_bug_entry' +$script:BUG_PROMOTION_TYPE = 'bug' +$script:PROMOTION_TYPE_KEY = 'promotion-type' + +# The two checkpoint fields that must exist as empty lists at completion. +$script:COMPLETION_EMPTY_LIST_KEYS = @('local_execution_overrides', 'delegation_bypasses') + +# The lifecycle-operation surface every recorded operation must have used. +$script:LIFECYCLE_MCP_SURFACE = 'mcp' + + +function Get-CheckpointNonBlankStringList { + <# + .SYNOPSIS + Return a value as a list of non-blank strings, or $null when malformed. + .DESCRIPTION + Private helper mirroring the `_string_list` variant used by the routing + contract, which requires every member to be a non-blank string. A non-list + value, or any blank or non-string member, disqualifies the whole value. + .PARAMETER Value + The deserialized JSON value to inspect. May be $null. + .OUTPUTS + System.Collections.Hashtable with keys Ok (bool) and Value (string[]). + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $Value + ) + + if (-not (Test-CheckpointListValue -Value $Value)) { + return @{ Ok = $false; Value = [string[]]@() } + } + foreach ($item in @($Value)) { + if (-not ($item -is [string]) -or [string]::IsNullOrWhiteSpace([string]$item)) { + return @{ Ok = $false; Value = [string[]]@() } + } + } + return @{ Ok = $true; Value = [string[]]@($Value) } +} + +function Get-ResolvedRequiredMcpTool { + <# + .SYNOPSIS + Resolve the promotion-entry MCP tool to the checkpoint's promotion type. + .DESCRIPTION + Private helper mirroring _resolve_promotion_entry_tools. Only an explicit + bug-type promotion swaps the promotion-entry tool; a feature type, an + absent key, a non-string value, and any other value leave the matrix list + untouched, so feature-type and legacy checkpoints validate exactly as + before. Matrix order is preserved. + .PARAMETER RequiredMcpTool + The route's declared required_mcp_tools list, in matrix order. + .PARAMETER State + The parsed checkpoint object, read for its hyphenated promotion-type key. + .OUTPUTS + System.String[] - the resolved tool list, in matrix order. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]] $RequiredMcpTool, + + [Parameter(Mandatory = $true)] + [psobject] $State + ) + + $promotionType = (Get-CheckpointObjectMember -Owner $State -Name $script:PROMOTION_TYPE_KEY).Value + if (-not (Test-PythonValueEqual -Actual $promotionType -Expected $script:BUG_PROMOTION_TYPE)) { + return [string[]]@($RequiredMcpTool) + } + + # Substitute the bug-type promotion-entry tool for the feature-type one while + # preserving matrix order and every other required tool exactly. + $resolved = foreach ($tool in $RequiredMcpTool) { + if ($tool -ceq $script:FEATURE_PROMOTION_ENTRY_TOOL) { $script:BUG_PROMOTION_ENTRY_TOOL } else { $tool } + } + return [string[]]@($resolved) +} + +function Get-CheckpointReceiptAgentName { + <# + .SYNOPSIS + Collect the agent names recorded by delegation receipts. + .DESCRIPTION + Private harvest mirroring _receipt_agents plus _list_receipts. The object + form of delegation_receipts contributes through its `agents` namespace; + any other non-list value contributes nothing. Only a non-blank string + agent_name counts. + .PARAMETER State + The parsed checkpoint object. + .OUTPUTS + System.String[] - the recorded agent names. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State + ) + + $receipts = (Get-CheckpointObjectMember -Owner $State -Name 'delegation_receipts').Value + if (Test-CheckpointObjectValue -Value $receipts) { + $receipts = (Get-CheckpointObjectMember -Owner $receipts -Name 'agents').Value + } + + $agents = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + if (-not (Test-CheckpointListValue -Value $receipts)) { return [string[]]@($agents) } + + # Harvest each well-formed receipt's agent name; a malformed entry is skipped + # rather than reported, because shape errors belong to the U5 family. + foreach ($receipt in @($receipts)) { + if (-not (Test-CheckpointObjectValue -Value $receipt)) { continue } + $name = (Get-CheckpointObjectMember -Owner $receipt -Name 'agent_name').Value + if (($name -is [string]) -and -not [string]::IsNullOrWhiteSpace([string]$name)) { + [void]$agents.Add([string]$name) + } + } + return [string[]]@($agents) +} + +function Get-CheckpointAcknowledgedName { + <# + .SYNOPSIS + Collect acknowledged names from a receipt array with an evidence rule. + .DESCRIPTION + Private harvest shared by _receipt_skills and _mcp_tools, which differ + only in the array key, the name key, and the boolean flag key. A receipt + counts only when its name is a non-blank string, its flag is exactly the + boolean true, and its evidence is a non-blank string. Truthy-but-not-true + flags deliberately do not count. + .PARAMETER State + The parsed checkpoint object. + .PARAMETER ArrayKey + The checkpoint key holding the receipt array. + .PARAMETER NameKey + The per-receipt key holding the acknowledged name. + .PARAMETER FlagKey + The per-receipt key that must be exactly boolean true. + .OUTPUTS + System.String[] - the acknowledged names. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State, + + [Parameter(Mandatory = $true)] + [string] $ArrayKey, + + [Parameter(Mandatory = $true)] + [string] $NameKey, + + [Parameter(Mandatory = $true)] + [string] $FlagKey + ) + + $names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $receipts = (Get-CheckpointObjectMember -Owner $State -Name $ArrayKey).Value + if (-not (Test-CheckpointListValue -Value $receipts)) { return [string[]]@($names) } + + # All three conditions must hold together: an acknowledged name, an explicit + # true flag, and non-blank evidence. A receipt failing any one contributes + # nothing, so the requirement it would have satisfied is reported missing. + foreach ($receipt in @($receipts)) { + if (-not (Test-CheckpointObjectValue -Value $receipt)) { continue } + $name = (Get-CheckpointObjectMember -Owner $receipt -Name $NameKey).Value + $flag = (Get-CheckpointObjectMember -Owner $receipt -Name $FlagKey).Value + $evidence = (Get-CheckpointObjectMember -Owner $receipt -Name 'evidence').Value + if (($name -is [string]) -and -not [string]::IsNullOrWhiteSpace([string]$name) -and + ($flag -is [bool]) -and [bool]$flag -and + ($evidence -is [string]) -and -not [string]::IsNullOrWhiteSpace([string]$evidence)) { + [void]$names.Add([string]$name) + } + } + return [string[]]@($names) +} + +function Get-CompletionEmptyListError { + <# + .SYNOPSIS + Return the empty-list-at-completion errors for one key (rows C6.10/C6.11). + .DESCRIPTION + Private helper mirroring _validate_empty_list_field. The key must EXIST as + a list: an absent key, a null value, and any non-list value all produce the + "must be an empty list at completion" variant, while a present non-empty + list produces the "must be empty at completion" variant. + .PARAMETER State + The parsed checkpoint object. + .PARAMETER Key + The checkpoint key to check. + .OUTPUTS + System.String[] - zero or one error string. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State, + + [Parameter(Mandatory = $true)] + [string] $Key + ) + + $value = (Get-CheckpointObjectMember -Owner $State -Name $Key).Value + if (-not (Test-CheckpointListValue -Value $value)) { + return [string[]]@("Checkpoint $Key must be an empty list at completion.") + } + if (@($value).Count -gt 0) { + return [string[]]@("Checkpoint $Key must be empty at completion.") + } + return [string[]]@() +} + +function Get-LifecycleOperationError { + <# + .SYNOPSIS + Return the lifecycle-operation errors (rows C6.12-C6.14). + .DESCRIPTION + Private helper mirroring _validate_lifecycle_operations. The key is + optional: an absent or null value contributes nothing. A present non-list + value is malformed, and each recorded operation must be an object whose + surface is exactly the MCP surface. + .PARAMETER State + The parsed checkpoint object. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + $operations = (Get-CheckpointObjectMember -Owner $State -Name 'lifecycle_operations').Value + if ($null -eq $operations) { return $errors.ToArray() } + if (-not (Test-CheckpointListValue -Value $operations)) { + $errors.Add('Checkpoint lifecycle_operations must be a list when present.') + return $errors.ToArray() + } + + # Report each malformed or non-MCP operation with its own index. + $index = 0 + foreach ($operation in @($operations)) { + if (-not (Test-CheckpointObjectValue -Value $operation)) { + $errors.Add("Checkpoint lifecycle_operations #$index must be an object.") + $index++ + continue + } + $surface = (Get-CheckpointObjectMember -Owner $operation -Name 'surface').Value + if (-not (Test-PythonValueEqual -Actual $surface -Expected $script:LIFECYCLE_MCP_SURFACE)) { + $errors.Add("Checkpoint lifecycle_operations #$index did not use MCP surface.") + } + $index++ + } + + return $errors.ToArray() +} + +function Get-OrchestratorStateRoutingContractError { + <# + .SYNOPSIS + Return the routing-contract errors (inventory rows C6.1-C6.14). + .DESCRIPTION + Public entry mirroring validate_routing_contract. The first three rows are + terminal: a malformed matrix, an unselected route, and an unknown route + each return a single error, because none of the later checks can be + evaluated without a resolved route entry. The remaining rows accumulate. + .PARAMETER State + The parsed checkpoint object. + .PARAMETER RoutingMatrix + Optional matrix override. When omitted, the pinned matrix is used. A + matrix carrying no routes mapping is what makes row C6.1 reachable. + .OUTPUTS + System.String[] - zero or more error strings. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [hashtable] $RoutingMatrix = $null + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # C6.1: a matrix with no routes mapping cannot resolve any route. + $routes = Get-OrchestratorStateRoutingMatrixRouteMap -RoutingMatrix $RoutingMatrix + if ($null -eq $routes) { + return [string[]]@('Routing matrix missing routes object.') + } + + # C6.2: the checkpoint must name a route as a non-blank string. + $routeId = Get-OrchestratorStateSelectedRouteId -State $State + if ($null -eq $routeId) { + return [string[]]@('Checkpoint route_id or path_selected must select a route.') + } + + # C6.3: the named route must exist in the matrix. + $route = Get-OrchestratorStateRoute -RouteId $routeId -RoutingMatrix $RoutingMatrix + if ($null -eq $route) { + return [string[]]@("Checkpoint selected route has no routing-matrix entry: $routeId.") + } + + $requiredAgents = @(Get-OrchestratorStateRouteRequiredList -RouteId $routeId -ListName 'required_agents' -RoutingMatrix $RoutingMatrix) + $requiredSkills = @(Get-OrchestratorStateRouteRequiredList -RouteId $routeId -ListName 'required_skills' -RoutingMatrix $RoutingMatrix) + $requiredMcpTools = @(Get-ResolvedRequiredMcpTool -State $State ` + -RequiredMcpTool ([string[]]@(Get-OrchestratorStateRouteRequiredList -RouteId $routeId -ListName 'required_mcp_tools' -RoutingMatrix $RoutingMatrix))) + + # C6.4 to C6.6: the checkpoint's own declared lists must equal the matrix + # lists exactly, in order. A malformed list is a mismatch, not a shape error. + $declaredLists = @( + @{ Key = 'required_agents'; Expected = $requiredAgents }, + @{ Key = 'required_skills'; Expected = $requiredSkills }, + @{ Key = 'required_mcp_tools'; Expected = $requiredMcpTools } + ) + foreach ($declared in $declaredLists) { + $actual = Get-CheckpointNonBlankStringList -Value (Get-CheckpointObjectMember -Owner $State -Name $declared.Key).Value + if (-not $actual.Ok -or -not (Test-PythonValueEqual -Actual $actual.Value -Expected ([string[]]$declared.Expected))) { + $errors.Add("Checkpoint $($declared.Key) must match routing matrix for route $routeId.") + } + } + + # C6.7 to C6.9: every required agent, skill, and MCP tool must be evidenced by + # a receipt, in matrix order so the report is deterministic. + $actualAgents = @(Get-CheckpointReceiptAgentName -State $State) + foreach ($agent in $requiredAgents) { + if ($actualAgents -cnotcontains $agent) { + $errors.Add("Checkpoint missing required agent receipt: $agent.") + } + } + + $actualSkills = @(Get-CheckpointAcknowledgedName -State $State -ArrayKey 'skill_receipts' -NameKey 'skill' -FlagKey 'required') + foreach ($skill in $requiredSkills) { + if ($actualSkills -cnotcontains $skill) { + $errors.Add("Checkpoint missing required skill receipt: $skill.") + } + } + + $actualTools = @(Get-CheckpointAcknowledgedName -State $State -ArrayKey 'mcp_call_receipts' -NameKey 'tool' -FlagKey 'ok') + foreach ($tool in $requiredMcpTools) { + if ($actualTools -cnotcontains $tool) { + $errors.Add("Checkpoint missing successful MCP receipt: $tool.") + } + } + + # C6.10 to C6.14: the two empty-list fields and the lifecycle operations. + foreach ($key in $script:COMPLETION_EMPTY_LIST_KEYS) { + $errors.AddRange([string[]]@(Get-CompletionEmptyListError -State $State -Key $key)) + } + $errors.AddRange([string[]]@(Get-LifecycleOperationError -State $State)) + + return $errors.ToArray() +} + +# Only the family entry point is exported; the harvest and field helpers stay +# private so no consumer can evaluate a subset of the contract. +Export-ModuleMember -Function Get-OrchestratorStateRoutingContractError diff --git a/.claude/lib/orchestrator-state/OrchestratorStateRoutingMatrix.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateRoutingMatrix.psm1 new file mode 100644 index 000000000..cf9318c7e --- /dev/null +++ b/.claude/lib/orchestrator-state/OrchestratorStateRoutingMatrix.psm1 @@ -0,0 +1,377 @@ +<# +.SYNOPSIS + Pinned routing-matrix constants and route accessors for the portable checks. + +.DESCRIPTION + Implements deliberate deviation PD-1 exactly as the feature spec records it. + The subset of `config/orchestration-routing.json` that the completion checks + consume - each route's `requires_pr_gate`, `requires_ci_gate`, + `required_agents`, `required_skills`, and `required_mcp_tools` - is embedded + here as pinned constants, following the established `ModelRouting.psm1:33-39` + pattern. + + NO DISK READ AT VALIDATION TIME. This module never opens + `config/orchestration-routing.json`. That file is deliberately not shipped to + consumer repositories, and the Python reference crashes with an uncaught + FileNotFoundError in a repository that lacks it - even on a plain validator + call. A missing-config crash, or a blanket block, is precisely the portability + failure this feature exists to remove, so fail-closed-on-missing-config was + rejected in favour of pinned constants. The config is read only by the static + config-parity Pester test, which runs in drm-copilot where the file exists and + is the oracle that keeps these constants honest. + + Route-value resolution is exported too, because two different Python rules + exist and both must be reproduced: the routing-contract and preparation + checks read the raw `route_id` value (falling back to `path_selected` only + when the `route_id` KEY is absent), while the gate helpers additionally + require that value to be a non-blank string. + + Every accessor takes an optional -RoutingMatrix override so a caller can + supply an alternative matrix, mirroring the Python `routing_matrix` keyword. + The override exists for testability and for the malformed-matrix check; it is + never used to read from disk. + + Every function is pure: it reads no file, starts no process, and never mutates + its input. +#> + +Set-StrictMode -Version Latest + +# 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 + +# 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 +# means the key is ABSENT from the config, which is semantically distinct from +# $false: an absent requires_ci_gate keeps the CI gate required, while an absent +# requires_pr_gate leaves the PR gate not required. +$script:PINNED_ROUTES = @{ + small = @{ + requires_pr_gate = $null + requires_ci_gate = $null + required_agents = @('atomic-planner', 'atomic-executor', 'feature-review') + required_skills = @('orchestrate', 'feature-promotion-lifecycle', 'atomic-plan-contract', 'acceptance-criteria-tracking', 'pr-context-artifacts', 'pr-base-branch-merge-base') + required_mcp_tools = @('new_potential_entry', 'potential_to_issue', 'new_active_feature_folder', 'collect_pr_context', 'validate_orchestration_artifacts') + } + large = @{ + requires_pr_gate = $true + requires_ci_gate = $null + required_agents = @('task-researcher', 'prd-feature', 'atomic-planner', 'atomic-executor', 'feature-review', 'pr-author') + required_skills = @('orchestrate', 'feature-promotion-lifecycle', 'atomic-plan-contract', 'acceptance-criteria-tracking', 'pr-context-artifacts', 'pr-base-branch-merge-base') + required_mcp_tools = @('new_potential_entry', 'potential_to_issue', 'new_active_feature_folder', 'collect_pr_context', 'validate_orchestration_artifacts') + } + remediation = @{ + requires_pr_gate = $null + requires_ci_gate = $null + required_agents = @('atomic-planner', 'atomic-executor', 'feature-review') + required_skills = @('orchestrate', 'atomic-plan-contract', 'acceptance-criteria-tracking', 'pr-context-artifacts') + required_mcp_tools = @('collect_pr_context', 'validate_orchestration_artifacts') + } + preparation = @{ + requires_pr_gate = $null + requires_ci_gate = $false + required_agents = @('task-researcher', 'prd-feature', 'atomic-planner', 'atomic-executor') + required_skills = @('orchestrate', 'feature-promotion-lifecycle', 'atomic-plan-contract') + required_mcp_tools = @('new_potential_entry', 'potential_to_issue', 'new_active_feature_folder', 'validate_orchestration_artifacts') + } + parallel = @{ + requires_pr_gate = $false + requires_ci_gate = $null + required_agents = @('orchestrator', 'pr-author') + required_skills = @('parallel-orchestrate', 'orchestrate', 'feature-promotion-lifecycle', 'atomic-plan-contract', 'acceptance-criteria-tracking', 'evidence-and-timestamp-conventions', 'pr-context-artifacts', 'pr-base-branch-merge-base') + required_mcp_tools = @('collect_pr_context', 'validate_orchestration_artifacts') + } + epic = @{ + requires_pr_gate = $true + requires_ci_gate = $null + required_agents = @('orchestrator', 'pr-author') + required_skills = @('epic-orchestrate', 'orchestrate', 'feature-promotion-lifecycle', 'atomic-plan-contract', 'acceptance-criteria-tracking', 'evidence-and-timestamp-conventions', 'pr-context-artifacts', 'pr-base-branch-merge-base') + required_mcp_tools = @('collect_pr_context', 'validate_orchestration_artifacts') + } +} + +# The three per-route list names the completion and routing-contract checks read. +$script:ROUTE_LIST_NAMES = @('required_agents', 'required_skills', 'required_mcp_tools') + + +function Get-OrchestratorStateRoutingMatrix { + <# + .SYNOPSIS + Return the pinned routing matrix in the shape the Python matrix has. + .DESCRIPTION + Returns a hashtable with a single `routes` member, mirroring the top-level + shape of config/orchestration-routing.json so the accessors and the + malformed-matrix check operate on the same structure whether the matrix is + the pinned default or a caller-supplied override. No file is read. + .OUTPUTS + System.Collections.Hashtable with a `routes` member. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param() + + return @{ routes = $script:PINNED_ROUTES } +} + +function Get-OrchestratorStateRoutingMatrixRouteMap { + <# + .SYNOPSIS + Return a matrix's routes mapping, or $null when the matrix is malformed. + .DESCRIPTION + Private-shape accessor mirroring the Python `matrix.get("routes")` guard. + A matrix whose `routes` member is absent or is not a mapping yields $null, + which the routing-contract check reports as a malformed matrix. + .PARAMETER RoutingMatrix + The matrix to inspect. When omitted, the pinned matrix is used. + .OUTPUTS + System.Collections.Hashtable, or $null when the matrix carries no routes + mapping. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $false)] + [AllowNull()] + [hashtable] $RoutingMatrix = $null + ) + + $matrix = if ($null -ne $RoutingMatrix) { $RoutingMatrix } else { Get-OrchestratorStateRoutingMatrix } + if (-not $matrix.ContainsKey('routes')) { return $null } + $routes = $matrix['routes'] + if ($routes -isnot [hashtable]) { return $null } + return $routes +} + +function Get-OrchestratorStateRoute { + <# + .SYNOPSIS + Return one route's pinned entry, or $null when the route is unknown. + .DESCRIPTION + Accessor mirroring the Python `routes.get(route_id)` lookup plus its + `isinstance(raw_route, dict)` guard. A null or unknown route id, or a + malformed matrix, yields $null. + .PARAMETER RouteId + The route identifier. May be $null. + .PARAMETER RoutingMatrix + Optional matrix override. When omitted, the pinned matrix is used. + .OUTPUTS + System.Collections.Hashtable, or $null when the route is unknown. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string] $RouteId, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [hashtable] $RoutingMatrix = $null + ) + + if ([string]::IsNullOrEmpty($RouteId)) { return $null } + $routes = Get-OrchestratorStateRoutingMatrixRouteMap -RoutingMatrix $RoutingMatrix + if ($null -eq $routes -or -not $routes.ContainsKey($RouteId)) { return $null } + $route = $routes[$RouteId] + if ($route -isnot [hashtable]) { return $null } + return $route +} + +function Get-OrchestratorStateRawRouteValue { + <# + .SYNOPSIS + Return the checkpoint's raw route value without a string requirement. + .DESCRIPTION + Reproduces the Python expression `state.get("route_id", + state.get("path_selected"))`. The distinction matters: when the `route_id` + KEY is present its value is used even if that value is null, and only an + ABSENT `route_id` key falls back to `path_selected`. The preparation + terminal check compares this raw value directly. + .PARAMETER State + The parsed checkpoint object. + .OUTPUTS + System.Object - the raw route value, which may be $null or a non-string. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State + ) + + $routeIdField = Get-CheckpointObjectMember -Owner $State -Name 'route_id' + if ($routeIdField.Present) { return $routeIdField.Value } + return (Get-CheckpointObjectMember -Owner $State -Name 'path_selected').Value +} + +function Get-OrchestratorStateSelectedRouteId { + <# + .SYNOPSIS + Return the checkpoint's selected route id, or $null when unusable. + .DESCRIPTION + Reproduces the Python `_selected_route_id` helper: the raw route value is + usable only when it is a non-blank string. Every gate accessor and the + phase-completeness check resolve the route through this rule. + .PARAMETER State + The parsed checkpoint object. + .OUTPUTS + System.String - the route id, or $null when absent, non-string, or blank. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State + ) + + $value = Get-OrchestratorStateRawRouteValue -State $State + if (-not ($value -is [string]) -or [string]::IsNullOrWhiteSpace([string]$value)) { return $null } + return [string]$value +} + +function Test-OrchestratorStateRouteRequiresPrGate { + <# + .SYNOPSIS + Report whether a route requires the completion PR gate. + .DESCRIPTION + Mirrors `route_requires_pr_gate`. The gate applies only when the route + exists and its `requires_pr_gate` value is exactly the boolean true, so a + missing route id, an unknown route, and an absent flag all report false. + .PARAMETER RouteId + The route identifier. May be $null. + .PARAMETER RoutingMatrix + Optional matrix override. When omitted, the pinned matrix is used. + .OUTPUTS + System.Boolean + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string] $RouteId, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [hashtable] $RoutingMatrix = $null + ) + + $route = Get-OrchestratorStateRoute -RouteId $RouteId -RoutingMatrix $RoutingMatrix + if ($null -eq $route -or -not $route.ContainsKey('requires_pr_gate')) { return $false } + return (($route['requires_pr_gate'] -is [bool]) -and [bool]$route['requires_pr_gate']) +} + +function Test-OrchestratorStateRouteRequiresCiGate { + <# + .SYNOPSIS + Report whether a route requires the completion CI gate. + .DESCRIPTION + Mirrors `route_requires_ci_gate`. Only an explicit boolean false opts a + route out, so a missing route id, an unknown route, and an absent flag all + keep the CI gate required. The asymmetry with the PR gate is deliberate + and is the historical behaviour the Python reference preserves. + .PARAMETER RouteId + The route identifier. May be $null. + .PARAMETER RoutingMatrix + Optional matrix override. When omitted, the pinned matrix is used. + .OUTPUTS + System.Boolean + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string] $RouteId, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [hashtable] $RoutingMatrix = $null + ) + + $route = Get-OrchestratorStateRoute -RouteId $RouteId -RoutingMatrix $RoutingMatrix + if ($null -eq $route -or -not $route.ContainsKey('requires_ci_gate')) { return $true } + return -not (($route['requires_ci_gate'] -is [bool]) -and -not [bool]$route['requires_ci_gate']) +} + +function Get-OrchestratorStateRouteRequiredList { + <# + .SYNOPSIS + Return one of a route's three required-name lists. + .DESCRIPTION + Mirrors the Python `_route_list` helper: a route that does not carry the + named list, or carries a value that is not a list of non-blank strings, + contributes an empty list rather than an error. + .PARAMETER RouteId + The route identifier. May be $null. + .PARAMETER ListName + One of required_agents, required_skills, required_mcp_tools. + .PARAMETER RoutingMatrix + Optional matrix override. When omitted, the pinned matrix is used. + .OUTPUTS + System.String[] - the required names in matrix order, possibly empty. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string] $RouteId, + + [Parameter(Mandatory = $true)] + [ValidateSet('required_agents', 'required_skills', 'required_mcp_tools')] + [string] $ListName, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [hashtable] $RoutingMatrix = $null + ) + + $route = Get-OrchestratorStateRoute -RouteId $RouteId -RoutingMatrix $RoutingMatrix + if ($null -eq $route -or -not $route.ContainsKey($ListName)) { return [string[]]@() } + + # A malformed list contributes nothing, matching the Python helper's + # None-to-empty-list conversion rather than raising. + $value = $route[$ListName] + if ($value -isnot [System.Array]) { return [string[]]@() } + foreach ($item in $value) { + if (-not ($item -is [string]) -or [string]::IsNullOrWhiteSpace([string]$item)) { return [string[]]@() } + } + return [string[]]@($value) +} + +function Get-OrchestratorStateRouteListName { + <# + .SYNOPSIS + Return the three per-route required-name list names. + .DESCRIPTION + Read-only accessor so the routing-contract check and the config-parity + test iterate one declared set of list names instead of restating it. + .OUTPUTS + System.String[] - the three list names. + #> + [CmdletBinding()] + [OutputType([string[]])] + param() + + return [string[]]@($script:ROUTE_LIST_NAMES) +} + +# The matrix, the route lookup, both gate predicates, the required-list accessor, +# and both route-value resolvers are exported for the completion-checks and +# routing-contract modules and for the static config-parity test. +Export-ModuleMember -Function ` + Get-OrchestratorStateRoutingMatrix, ` + Get-OrchestratorStateRoutingMatrixRouteMap, ` + Get-OrchestratorStateRoute, ` + Get-OrchestratorStateRawRouteValue, ` + Get-OrchestratorStateSelectedRouteId, ` + Test-OrchestratorStateRouteRequiresPrGate, ` + Test-OrchestratorStateRouteRequiresCiGate, ` + Get-OrchestratorStateRouteRequiredList, ` + Get-OrchestratorStateRouteListName diff --git a/.claude/lib/orchestrator-state/OrchestratorStateUnconditional.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateUnconditional.psm1 new file mode 100644 index 000000000..c056ecc7e --- /dev/null +++ b/.claude/lib/orchestrator-state/OrchestratorStateUnconditional.psm1 @@ -0,0 +1,166 @@ +<# +.SYNOPSIS + Single entry point for the orchestrator-state unconditional check block. + +.DESCRIPTION + Composes the whole U family of the issue #475 parity inventory into one + portable call, mirroring the unconditional block of + `validate_orchestrator_state_text` in + `scripts/dev_tools/validate_orchestrator_state.py`: + + U2-U4 required keys, step-status validity, blocked_reason validity, from + the existing `Get-OrchestratorStateBasePresenceError` in + `OrchestratorState.psm1` + U5 delegation_receipts shape, from `OrchestratorStateReceipts.psm1` + U6.R remediation_loop cycles, same module + U6.H human_interaction shape, same module + U6.C complexity_assessments per-entry, from `OrchestratorStateModelReceipts.psm1` + U6.M model_routing_receipts per-entry, same module + U6.X codex_model_routing_receipts, from `OrchestratorStateCodexModelReceipts.psm1` + U6.T codex_topology_receipts, from `OrchestratorStateCodexTopologyReceipts.psm1` + + U1 (parse failure and non-object root) is the LOADER's contract, produced by + `Get-OrchestratorStateCheckpoint` in `OrchestratorState.psm1`. Every caller + runs the loader first and fails closed on its error before reaching this + function, so the loader and this function together are the complete U family. + The loader's path-prefixed message text is the one documented parity + divergence from the Python strings, recorded in the feature spec. + + KEY-GATED SEMANTICS ARE PRESERVED. Each optional-key family runs only when + its key is PRESENT on the checkpoint, exactly as the Python + `optional_key_validators` loop does. An absent key contributes zero errors and + never produces a "must be a list when present" message. The distinction + matters: a present key holding null is validated, an absent key is not. + + Families run in the Python reference's order so accumulated error output is + ordered identically. + + The function is pure: it reads no file, starts no process, and never mutates + its input. +#> + +Set-StrictMode -Version Latest + +# 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 + +# 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 +# reference. +$script:DELEGATION_RECEIPTS_KEY = 'delegation_receipts' + +# The optional-key families, in the Python reference's evaluation order. The +# dispatch below routes each present key to its validator by name; the list is +# declared here so the order is stated once and is visible at a glance. +$script:OPTIONAL_KEYS = @( + 'remediation_loop', + 'human_interaction', + 'complexity_assessments', + 'model_routing_receipts', + 'codex_model_routing_receipts', + 'codex_topology_receipts' +) + + +function Import-OrchestratorStateBaseModule { + <# + .SYNOPSIS + Ensure the shared base-presence command is available, importing it lazily. + .DESCRIPTION + Private helper following the guarded lazy-import pattern used by + .claude/hooks/validate-orchestrator-output.ps1. The base module is + imported only when its command is not already resolvable, so this module + can be loaded from inside that module's own call path without an + eager two-way import. + .OUTPUTS + None. + #> + [CmdletBinding()] + [OutputType([void])] + param() + + if (Get-Command -Name 'Get-OrchestratorStateBasePresenceError' -ErrorAction SilentlyContinue) { return } + Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorState.psm1') +} + +function Get-OrchestratorStateUnconditionalError { + <# + .SYNOPSIS + Return every unconditional-block error for a parsed checkpoint. + .DESCRIPTION + The single U-family entry point. Runs the base-presence checks (U2-U4), + the delegation-receipt shape checks (U5), and each optional-key family + (U6.R, U6.H, U6.C, U6.M, U6.X, U6.T) in the Python reference's order. + Every optional family is key-gated: it runs only when its key is present + on the checkpoint, so an absent key contributes zero errors. + + U1 is not produced here. Parse failure and a non-object root are the + loader's contract; callers run Get-OrchestratorStateCheckpoint first and + fail closed on its error before reaching this function. + .PARAMETER State + The parsed checkpoint object, as returned by the loader. + .OUTPUTS + System.String[] - zero or more error strings, in Python reference order. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] + [psobject] $State + ) + + Import-OrchestratorStateBaseModule + + $errors = [System.Collections.Generic.List[string]]::new() + + # U2-U4: required keys, step-status validity, blocked_reason validity. + $errors.AddRange([string[]]@(Get-OrchestratorStateBasePresenceError -State $State)) + + # U5: the delegation-receipt shape, guarded on a non-null value rather than + # key presence, matching the Python `receipts is not None` test. + $receipts = (Get-CheckpointObjectMember -Owner $State -Name $script:DELEGATION_RECEIPTS_KEY).Value + $errors.AddRange([string[]]@(Get-OrchestratorStateDelegationReceiptError -Value $receipts)) + + # U6: each optional-key family, in reference order, key-gated so an absent key + # never produces a "must be a list when present" message. The dispatch names + # every validator explicitly rather than invoking a command stored in a + # variable, so the enforcement-hook AST guard sees only constant command + # names and no dynamic invocation. + foreach ($key in $script:OPTIONAL_KEYS) { + $field = Get-CheckpointObjectMember -Owner $State -Name $key + if (-not $field.Present) { continue } + switch ($key) { + 'remediation_loop' { + $errors.AddRange([string[]]@(Get-OrchestratorStateRemediationLoopError -Value $field.Value)) + } + 'human_interaction' { + $errors.AddRange([string[]]@(Get-OrchestratorStateHumanInteractionError -Value $field.Value)) + } + 'complexity_assessments' { + $errors.AddRange([string[]]@(Get-OrchestratorStateComplexityAssessmentError -Value $field.Value)) + } + 'model_routing_receipts' { + $errors.AddRange([string[]]@(Get-OrchestratorStateModelRoutingReceiptError -Value $field.Value)) + } + 'codex_model_routing_receipts' { + $errors.AddRange([string[]]@(Get-OrchestratorStateCodexModelRoutingReceiptError -Value $field.Value)) + } + 'codex_topology_receipts' { + $errors.AddRange([string[]]@(Get-OrchestratorStateCodexTopologyReceiptError -Value $field.Value)) + } + } + } + + return $errors.ToArray() +} + +# Only the aggregate entry point is exported; the lazy-import helper is private. +Export-ModuleMember -Function Get-OrchestratorStateUnconditionalError diff --git a/.claude/rules/csharp.md b/.claude/rules/csharp.md index 3e2a3355f..143866c58 100644 --- a/.claude/rules/csharp.md +++ b/.claude/rules/csharp.md @@ -11,14 +11,9 @@ This rule file summarizes the C#-specific policies for this repository. ## Toolchain -1. **Formatting — CSharpier**: All C# source files must be formatted with CSharpier. Do not use `dotnet format`. Run `dotnet tool restore` first when the manifest tool has not been restored. Apply: `dotnet tool run csharpier format .` Verify (CI parity, read-only): `dotnet tool run csharpier check .` Always invoke through `dotnet tool run` so the `dotnet-tools.json` pinned version is used; do not invoke a globally installed `csharpier`. -2. **Linting — .NET Analyzers**: C# code must pass Roslyn/.NET analyzer diagnostics. Command: `msbuild <solution>.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` - - Use `/t:Rebuild` so the step always performs a genuine recompile; a warm `/t:Build` skips `CoreCompile` and runs no analyzers. CI's analyzer job (`.github/workflows/_build-analyzers.yml`) retains `/t:Build /m` because a runner checkout is always cold. -3. **Type Checking — Nullable Analysis**: Nullable analysis is per-file opt-in via `#nullable enable`; the gate promotes the resulting diagnostics to errors. Command: `msbuild <solution>.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` - - This is CI's nullable job (`.github/workflows/_build-nullable.yml`) command verbatim. Do not add - `/p:Nullable=enable` (no project carries a `<Nullable>` element; the flag opts in every - un-annotated file at once and makes the gate unpassable) and do not use `/t:Build` (a warm build - skips `CoreCompile` and the gate cannot fail). +1. **Formatting — CSharpier**: All C# source files must be formatted with CSharpier. Do not use `dotnet format`. Run `dotnet tool restore` first when the manifest tool has not been restored. Apply formatting with `dotnet tool run csharpier format .` and verify read-only with `dotnet tool run csharpier check .`. Always invoke through `dotnet tool run` so the manifest-pinned CSharpier version is used. +2. **Linting — .NET Analyzers**: C# code must pass Roslyn/.NET analyzer diagnostics. Command: `msbuild <solution>.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true`. `/t:Rebuild` is intentional for a warm local worktree: `/t:Build` can skip `CoreCompile` through MSBuild incrementality and exit 0 without running analyzers. CI may retain `/t:Build` on a cold checkout. +3. **Type Checking — Nullable Analysis**: Compiler and nullable-flow diagnostics must pass with warnings as errors. Command: `msbuild <solution>.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true`. `/t:Rebuild` is required locally so compiler and nullable-flow diagnostics actually run. Projects opt into nullable per file with `#nullable enable`; do not pass `/p:Nullable=enable`, which opts every unannotated file in at once. 4. **Testing — MSTest + Moq + FluentAssertions**: Run tests with: `vstest.console.exe <test-assembly-paths> /EnableCodeCoverage` Run the toolchain in order: format → lint → type-check → test. Restart from step 1 if any step fails or changes files. @@ -85,7 +80,7 @@ This repository adopts a fixed set of FIVE static-analysis packages, wired into ### Severity-first ordering invariant -All new analyzer rule severities are configured in `.editorconfig` at `severity = suggestion` (never `warning`/`error`) BEFORE any `<Analyzer Include>` item is wired into a project. This is required because the type-check toolchain step runs `msbuild ... /t:Rebuild /m ... /p:TreatWarningsAsErrors=true`, which promotes any `warning`-severity analyzer diagnostic to a build error. Keeping new analyzer diagnostics at `suggestion` (message level) prevents the analyzer adoption from breaking the protected nullable gate. +All new analyzer rule severities are configured in `.editorconfig` at `severity = suggestion` (never `warning`/`error`) BEFORE any `<Analyzer Include>` item is wired into a project. This is required because the type-check toolchain step runs `msbuild ... /p:TreatWarningsAsErrors=true`, which promotes any `warning`-severity analyzer diagnostic to a build error. Keeping new analyzer diagnostics at `suggestion` (message level) prevents the analyzer adoption from breaking the protected nullable gate. ### Deferred analyzer — SecurityCodeScan.VS2019 diff --git a/.claude/rules/general-unit-test.md b/.claude/rules/general-unit-test.md index 5137360c8..6b70ee410 100644 --- a/.claude/rules/general-unit-test.md +++ b/.claude/rules/general-unit-test.md @@ -21,7 +21,7 @@ Every unit test must satisfy all five of these properties: ## Coverage Requirements - **Line coverage must remain >= 85% across all tiers (T1–T4).** -- **Branch coverage must remain >= 75% across all tiers (T1–T4).** +- **Branch coverage must remain >= 75% across all tiers (T1–T4) for languages whose coverage tooling measures branch coverage.** PowerShell (Pester) and bash (kcov) are the exceptions: neither tool measures branch coverage in any output format, so only the line threshold applies to them and there is no branch-coverage gate. This is a threshold exemption only; PowerShell and bash production files remain in the coverage denominator under the Coverage Exclusion Policy below. - Code changes or refactors must not reduce coverage for the lines that were changed. - Tier-specific lower coverage thresholds are not used in this repository. See `.claude/rules/quality-tiers.md` for the full tier system. - Coverage is a supporting metric, not the sole quality gate. Untested critical behavior is not acceptable even if the overall percentage looks good. diff --git a/.claude/rules/mermaid.md b/.claude/rules/mermaid.md new file mode 100644 index 000000000..000c88125 --- /dev/null +++ b/.claude/rules/mermaid.md @@ -0,0 +1,142 @@ +--- +paths: + - "**/*.mmd" + - "**/*.mermaid" +description: Mermaid diagram authoring standards, validation mandate, and managed-diagram constraint. +--- + +# Mermaid Diagram Standards + +This rule governs Mermaid diagrams in this repository. The authoring workflow, the per-type syntax +references, and the generation recipes live in `.claude/skills/mermaid-diagram/SKILL.md`; this file +carries the constraints. + +The pinned Mermaid documentation version for the whole surface is **11.17.0** +(https://mermaid.js.org/intro/syntax-reference.html). The keyword allowlist and the per-type arrow +token sets are a snapshot of that version, recorded in `.claude/lib/mermaid/MermaidGrammar.psm1`. + +## Diagram File Conventions + +- A standalone diagram belongs in a `.mmd` file (`.mermaid` is also recognized). The whole file is + one diagram: optional YAML frontmatter, optional `%%{init: ...}%%` directives, the diagram-type + keyword line, then the body. +- A diagram embedded in prose belongs in a fenced ` ```mermaid ` block in the Markdown file that + discusses it. GitHub renders such fences natively in Markdown, pull requests, and issues. +- The first line after any frontmatter, directive, comment, and blank line must be the + diagram-type keyword. Mermaid keywords are case-sensitive: `C4Context`, `stateDiagram-v2`, and + `sequenceDiagram` are exact spellings. +- Mermaid has no backslash escape. To place a double quote inside a label, use the `#quot;` entity; + a backslash before a closing quote closes the span rather than escaping it. +- Do not commit a diagram file as a test fixture. Diagram fixtures belong in PowerShell + here-strings inside the Pester suites, because a `PreToolUse` hook fires on the write of its own + fixtures. + +## Validation Mandate + +Every diagram written to this repository passes through the structural gate +`.claude/hooks/enforce-mermaid-validation.ps1` on `Write` and `Edit`. The gate is registered in the +`Write|Edit` matcher of `.claude/settings.json` and runs the dependency-free validator in +`.claude/lib/mermaid/`. + +What the gate rejects, naming the defect class and the line number: + +- a missing, non-keyword, or misspelled first-line diagram keyword; +- YAML frontmatter that opens with `---` and is never closed; +- an empty or whitespace-only diagram body; +- unbalanced `[]`, `()`, or `{}` on structural lines of a bracket-structural diagram type, + computed by a quote-aware scanner; +- an unterminated double-quoted label; +- an arrow or edge token that is not valid for the declared diagram type; +- a `subgraph` with no matching `end`. + +What the gate does NOT do, stated plainly so "validated" is not overclaimed: it does not prove a +diagram renders, and it performs no parse. Semantic and deep-grammar errors — an undefined node +reference in a `click` statement, a malformed gantt date, an invalid `classDef` property, a wrong +`section` structure, an invalid participant reference — are outside its reach. A `Valid` verdict +means no defect of a checked class was found, nothing more. + +Where the gate declines to judge, it allows. An unknown but keyword-shaped first-line token is +allowed with a drift warning, because the allowlist is a pinned snapshot and an out-of-date +allowlist must cost a warning rather than a false rejection. Diagram types outside the deep-checked +set (flowchart, sequence, class, state, ER) are keyword-checked only. An `Edit` payload carries a +fragment rather than the resulting file, so the syntax check is not attempted and the next `Write` +catches a regression. + +## Managed Diagrams: Do Not Hand-Edit + +A `.mmd` or `.mermaid` file whose frontmatter carries an `id:` key is connected to the Mermaid Chart +cloud sync workflow: + +```yaml +--- +id: cbd9e9ba-a2cb-47c5-a98e-8c28a753428d +--- +``` + +Such a diagram must not be hand-edited. The next sync overwrites the edit, so the change is lost +and the diff is misleading in the meantime. The gate denies both `Write` and `Edit` on a file whose +on-disk frontmatter carries a non-empty `id:`, with the reason token +`MERMAID_MANAGED_DIAGRAM_BLOCKED:`. + +To change a managed diagram, use the Mermaid Chart sync workflow in VS Code (Mermaid Chart +extension: **Sync Diagram with Mermaid**, then **Review Mermaid Sync**) and pull the synced result. +Connecting a diagram, reviewing a sync, and accepting or rejecting synced commits are interactive +VS Code actions; they are human steps, not automatable from a Claude Code session. + +The opt-out marker below never suppresses this constraint: the marker applies to fenced blocks in +Markdown, and the managed-diagram gate is keyed on diagram file paths. + +## Opt-Out Marker for Deliberate Counter-Examples + +Documentation legitimately quotes invalid Mermaid to demonstrate a defect. Placing the exact HTML +comment on the line immediately preceding a fence suppresses validation for that one block: + +```text +<!-- mermaid-validator: ignore --> +``` + +Rules for the marker: + +- The comment text is exactly `mermaid-validator: ignore`, case-sensitive. Whitespace around the + line and inside the comment delimiters is permitted. +- It must sit on the line immediately before the opening ` ```mermaid ` fence, with no intervening + line, blank or otherwise. +- Its scope is exactly one block. A second counter-example needs its own marker; an unmarked + invalid block in the same file is still denied. +- It applies only to fenced blocks in Markdown. Diagram files have no opt-out: a diagram file is by + definition a diagram. +- A ` ```mermaid ` fence nested inside an outer, longer fence is already treated as example text + rather than a diagram and needs no marker. + +## Out of Scope: The Non-Portable Extension Mechanisms + +The Copilot instruction pack at `.github/instructions/mermaid.instructions.md` relies on VS Code +extension mechanisms that no Claude Code session can invoke. They are recorded here so a later +reader does not read the omission as an oversight. The same record appears in +`.claude/skills/mermaid-diagram/SKILL.md`. + +| Mechanism | Why it is not ported | What replaces it | +| --- | --- | --- | +| `mermaid-diagram-validator` LM tool | A VS Code Language Model API tool contributed by the extension; not an MCP tool and not callable from a Claude session | The structural gate in this repository, weaker than a real parse and documented as such | +| `mermaid-diagram-preview` LM tool, `mermaidChart.preview` | A VS Code webview | The conditional rendering paths in the skill; GitHub renders fences natively | +| `get-syntax-docs-mermaid` LM tool | A VS Code LM API tool | The bundled per-type references under the skill, pinned to 11.17.0, with a documented `WebFetch` fallback to mermaid.js.org | +| The sixteen `mermaidChart.*` command IDs | Each needs the VS Code command API, an active editor, and the extension host | Portable capabilities are ported by substitution: validation to the hook, generation to the skill recipes, preview to conditional rendering, sync cooperation to the `id:` guard. The command IDs themselves are not ported | +| `@mermaid-chart` Copilot Chat slash commands | Copilot Chat participants do not exist on the Claude surface | The eight generation intents are skill recipe sections | +| `mermaidChart.repairDiagram`, `mermaidChart.improveDiagram` | Mermaid AI credits and extension UI | The gate's specific defect messages plus ordinary editing. No credit-consuming path exists on this surface, so there is nothing to warn about | +| `mermaidChart.login`, `logout`, `connectDiagramToMermaidChart`, `syncDiagramWithMermaid`, `reviewAppCommits`, `regenerateDiagramWithMermaidAI` | Interactive OAuth and extension UI against the Mermaid Chart cloud | Human steps in VS Code. The automatable half is in scope and delivered: the `id:` managed-diagram guard above | +| `mermaidChart.createMermaidFile`, `mermaidChart.installAiSkills` | Extension UI; the second is the Copilot-surface distribution mechanism | Creating a diagram file is an ordinary `Write`. The Claude distribution mechanism is the bundled resources mirror plus the `pack-manifests/core.json` entry | +| Deep `mmdc`/Chromium validation | Chromium-backed, seconds-level latency, no validate-only mode; unfit for a per-write gate | Recorded as an optional CI-side follow-up consuming the validator's structured result | + +## Example + +A minimal valid diagram file, frontmatter included: + +```mermaid +--- +title: Request handling +--- +flowchart LR + A[Client] --> B{Authenticated} + B -->|yes| C[Handler] + B -->|no| D[Reject] +``` diff --git a/.claude/rules/parallel-orchestration.md b/.claude/rules/parallel-orchestration.md index 7063d2dab..6438dec8b 100644 --- a/.claude/rules/parallel-orchestration.md +++ b/.claude/rules/parallel-orchestration.md @@ -26,7 +26,7 @@ Enforced by `validate_parallel_orchestrator_state_text(text, *, require_complete 3. **Mode enum.** `mode` must be `closed` or `open`. -4. **Bounded concurrency.** `max_concurrency` must be an integer from 1 through 8, and must not be a boolean. +4. **Bounded concurrency.** `max_concurrency` must be an integer from 1 through 32, and must not be a boolean. 5. **Item uniqueness and shape.** Each `items[]` entry must be an object whose `issue_num` is a positive integer unique across items and whose `feature_folder` is a non-empty string. @@ -98,7 +98,7 @@ Enforced by `validate_parallel_manifest_text(text)` in `scripts/dev_tools/parall - **M3 — Mode default.** `mode`, when present, must be `closed` or `open`. When absent it defaults to `closed`: the accessor `manifest_mode(mapping)` returns the default and the validator emits no error for absence. -- **M4 — Concurrency default.** `max_concurrency`, when present, must be an integer from 1 through 8. When absent it defaults to `4`: the accessor `manifest_max_concurrency(mapping)` returns the default and the validator emits no error for absence. +- **M4 — Concurrency default.** `max_concurrency`, when present, must be an integer from 1 through 32. When absent it defaults to `4`: the accessor `manifest_max_concurrency(mapping)` returns the default and the validator emits no error for absence. - **M5 — Created-at.** `created_at` must be a non-empty string. @@ -106,6 +106,22 @@ Enforced by `validate_parallel_manifest_text(text)` in `scripts/dev_tools/parall - **M7 — Prohibited keys.** No `depends_on` key may appear at any level, and no `integration_branch` key may appear at top level. Presence is an explicit rejection. +- **M8 — Expected conflict components (optional assertion).** `expected_conflict_components`, when present, must be a list. Each entry must be an object carrying a required `members` list that is non-empty and holds positive integers, each of which resolves to an `items[].issue_num`, with no `issue_num` appearing in more than one component; and an optional `name` that, when present, must be a non-empty string. When the key is ABSENT the invariant contributes zero errors and the manifest's error list is byte-identical to what it was before M8 existed. + + The value must be authored as a YAML BLOCK sequence. The destination-runtime bash YAML subset parser (`.claude/lib/bash/parallel-yaml-scan.sh`) rejects a non-empty flow collection, so a flow-style value such as `members: [101, 102]` is outside the supported subset and is not accepted on the bash path. + + `expected_conflict_components` is an ASSERTION, not a declaration. It NEVER overrides a derived conflict edge, NEVER feeds `compute_cohorts`, and NEVER influences scheduling. It is consumed by a planner diagnostic (`scripts/dev_tools/parallel_lane_assertion.py`), invoked advisory-only, whose findings never block. Its name deliberately references the DERIVED conflict graph: the field asserts what the operator expects blast-radius derivation to produce, and a mismatch is a signal to re-examine the radii, never a licence to edit the graph. The prohibition on narrowing a radius beyond the configured exclusions to suppress an edge is unaffected, as is the `depends_on` prohibition of invariant 10, P3, and M7 — this key is not a dependency edge and does not express ordering. + + Example, in the mandatory block-sequence form: + + ```yaml + expected_conflict_components: + - name: hooks-lane # optional, diagnostic label only + members: # required, non-empty, positive ints + - 101 + - 102 + ``` + ## Cache Doctrine — the checkpoint is not the source of truth The parallel-orchestrator checkpoint is a CACHE of durable state, not the source of truth. Every field it records is re-derivable from the repository and from GitHub: @@ -135,10 +151,19 @@ Per-item `merge_commit_sha` is retained; only the run-level merge-pull-request b ## Concurrency Bound (A7) -`max_concurrency` is bounded at 1 through 8 inclusive and defaults to `4` when absent from the manifest. The design document sets only the default of 4; the upper bound of 8 is adopted here for symmetry with the epic surface, whose `max_parallel_features` is validated as `1..8`. The bound is recorded in this rule file so that downstream features do not re-litigate it. Booleans are rejected even though `True` and `False` are integers in Python. +`max_concurrency` is bounded at 1 through 32 inclusive and defaults to `4` when absent from the manifest. The design document sets only the default of 4. Booleans are rejected even though `True` and `False` are integers in Python. + +The upper bound is derived from a constraint analysis of this surface alone. No other surface's bound is a reason for it. The findings recorded here so that downstream features do not re-litigate them: + +- **No constraint binds hard below O(100) concurrent worktrees.** Git worktrees, per-item feature branches, checkpoint size, and the cohort-coloring computation all scale well past a hundred concurrent items; none of them fails, or degrades sharply, anywhere near 32. +- **The first-binding constraint is GitHub Actions job concurrency**, which begins to bite at roughly 10 to 20 concurrent items on a typical plan. It binds by QUEUING, not by failing: excess jobs wait for a runner and the run completes more slowly. A `max_concurrency` above that point is therefore not an error, merely a setting whose marginal throughput is absorbed by the queue. +- **The ceiling of 32 is a SANITY limit, not a capacity limit.** Its purpose is to reject an order-of-magnitude operator typo (`320` for `32`), not to express a supported maximum. Do not read a value at or below 32 as an assurance that the runner pool can serve it. +- **Under the per-edge cohort barrier `max_concurrency` is a pure throughput throttle.** Mutual exclusion inside a conflict component is automatic: a conflicting neighbour in a strictly prior current-generation cohort must be `merged` or `worktree_removed` before an item starts, so raising the cap can never co-schedule two conflicting items. Raising it changes only how many independent lanes advance at once. The bound is enforced in three places with the same semantics: orchestrator invariant 4, planner invariant P2, and manifest invariant M4. +The epic surface is unaffected. `max_parallel_features` remains bounded at `1..8`; it is a different field on a different surface and is not changed by this bound. + ## Drift-Event Recording Rule (A8) `drift_events[].action` is the two-member enum `{raised_blocking_finding, halted_later_started_item}`. The recording rule is: one event per drift occurrence, carrying the STRONGEST action taken. `halted_later_started_item` subsumes `raised_blocking_finding`, so an occurrence that halted a later-started item records exactly one event with `action == 'halted_later_started_item'` and does not additionally record a `raised_blocking_finding` event for the same occurrence. @@ -173,6 +198,65 @@ F3 deliberately excludes the kickoff-prompt contract module `scripts/dev_tools/p F3's `require_ready_for_execution` gate is STRUCTURAL ONLY. It enforces the kickoff-PATH invariant (P9: `kickoff_prompt_path` must equal `artifacts/orchestration/parallel-kickoff-<parallel_slug>.md`) and does not parse or cross-check kickoff CONTENT. The deeper readiness-integrity machinery of the epic surface — git-integrity checks, launch-evidence binding, and kickoff-contract cross-checks — is left to F4, which may layer repository-aware checks behind an additional keyword without changing the schema. F3 likewise does not recompute the cohort coloring (planner invariant P5). +## Blast-Radius Contention Doctrine (issue #489) + +The conflict graph that seeds cohorts is only as good as the evidence that produces its edges. Two +classes of derivation defect made thematically unrelated items contend, and the corrections below +are part of the landed contract. Enforcement remains prose plus validator logic; no JSON Schema is +authored, imported, or read for any of it. + +### Read-by-mandate classification + +Every agent in this repository is instructed to read the policy rules, the tier map, and the process +artifacts before doing any work. A plan that cites `.claude/rules/python.md` or `quality-tiers.yml` +is therefore reporting compliance with the reading order, not declaring that its diff will write +those files. Counting such a citation as contention made every well-formed plan collide with every +other well-formed plan. + +`config/blast-radius.json` carries an optional `mandate_reads` list enumerating those paths as exact +entries and `**` subtree globs. That list is the mandate-read exclusion set. `derive_blast_radius` removes matching citations from the harvest +before resolving modules and shared surfaces, and `validate_blast_radius` removes them from its +plan-side extraction so V1 and V2 stay self-consistent against a radius derived from the same plan. +The key is optional and fail-closed: a truth table that omits it excludes nothing and reproduces +pre-change behaviour exactly. + +Three constraints bound the mandate-read exclusion: + +1. **The planner remains obliged to enumerate a genuine write explicitly.** An exclusion describes + the default reading relationship, not a permanent ban. When an item's plan will actually write an + excluded path, the planner appends that exact path to the declared radius after normalization. +2. **`quality-tiers.yml` stays a shared surface.** It is listed in both `shared_surfaces` and + `mandate_reads`: the first governs what happens when an item really writes it, the second governs + what happens when an item merely cites it. +3. **`detect_escaped_paths` makes the read/write distinction exact at execution time.** The + derivation heuristic reads intent from plan text and can be wrong in either direction; drift + detection compares the declared radius against the paths a diff actually touched, so an item that + wrote an excluded path is caught against observed evidence rather than against prose. + +The extractor additionally rejects three token shapes that were never write claims: a wildcard-free +token whose final component names a directory rather than a file, a `docs/features/` glob whose +wildcard occupies or truncates the feature-folder segment, and a contract token carrying no ASCII +letter. `artifacts/` is not a known top-level segment, so a bare `artifacts/**` subtree claim no +longer satisfies the shape rules. + +### Module-map granularity criterion + +Issue #472 removed the location-bucket modules `docs` and `tests` because a bucket keyed on where a +file lives rather than on which subsystem owns it attaches to nearly every work item. The same +reasoning extends to umbrella buckets keyed on a top-level directory that essentially every item +writes into: an umbrella that matches almost every radius is not a coherent unit of contention, +because a level that always fires carries no information and only suppresses concurrency. + +Under that criterion `python-dev-tools`, `vscode-extension`, `claude-runtime`, `copilot-surface`, +and `agents-surface` were removed, leaving the seven subsystem modules `mcp-server`, `benchmarks`, +`poshqc`, `powershell-dev-tools`, `codex-runtime`, `config`, and `schemas`. Removing a module never +weakens the relation below the path level: two items editing the same file still contend on +`path_overlap`, and two items editing a declared shared surface still contend on +`shared_surface_overlap`. + +A candidate module belongs in the map when it names a subsystem an item could plausibly not touch. +A candidate that matches the majority of work items belongs nowhere. + ## Enforcement - `scripts/dev_tools/validate_parallel_orchestrator_state.py`, with the helper modules `scripts/dev_tools/_parallel_state_common.py`, `scripts/dev_tools/_parallel_state_structures.py`, and `scripts/dev_tools/_parallel_state_records.py`, appends one error per violated orchestrator invariant. The completion-gate invariants 20 and 21 run only when the caller passes `require_complete=True`. @@ -182,3 +266,4 @@ F3's `require_ready_for_execution` gate is STRUCTURAL ONLY. It enforces the kick - The TypeScript parity port at `extensions/drm-copilot/src/lib/validate/parallel-state-shared.ts`, `parallel-state-structures.ts`, `parallel-state-records.ts`, `parallel-orchestrator-state-core.ts`, and `parallel-planner-state-core.ts` reproduces the same invariants and is dispatched from `extensions/drm-copilot/src/lib/validate/orchestration-artifacts.ts` for both new `artifact_type` values. Verified scope: 96 of 96 error strings matched across 43 constructed documents, for JSON-representable values that round-trip through both runtimes' native types. Three divergence classes are known outside that verified scope: (1) **`pythonRepr` quote selection** — `parallel-state-shared.ts:112-132` always single-quotes, while Python's `repr` switches to double quotes when the value contains a single quote (recorded repo-wide at `docs/features/potential/2026-08-07-python-repr-quote-selection-divergence.md`); (2) **integral floats** — `JSON.parse` erases Python's `int`/`float` distinction, so an integral float value produces a different Python-side error count than the TypeScript side; (3) **boolean/integer equality** — `parallel-state-structures.ts:228` uses `===`, so a boolean value is not selected the way Python's `True == 1` equality selects it, producing differing error counts. - Enforcement is therefore Python validator logic, plus the TypeScript parity port, plus this prose file. It is NEVER an imported JSON Schema. No schema file is read at validation time. - The `parallel` route entry lives in `config/orchestration-routing.json` with `requires_pr_gate: false` (there is no run-level pull request to gate; each child's own route checkpoint enforces its per-item pull-request gate) and is mirrored byte-for-byte in `extensions/drm-copilot/resources/config/orchestration-routing.json`. +- The `PreToolUse` merge gate `.claude/hooks/enforce-epic-merge-gate.ps1` carries a parallel allow-branch that authorizes a per-item `gh pr merge --merge` from the parallel-orchestrator checkpoint when `route_id == "parallel"`, the target item's `merge_status == "ci_green"`, and the command's PR number matches that item's `pr_number`; any other case fails closed with `EPIC_MERGE_GATE_BLOCKED`. diff --git a/.claude/rules/plan-acceptance-gates.md b/.claude/rules/plan-acceptance-gates.md new file mode 100644 index 000000000..fdfc99c15 --- /dev/null +++ b/.claude/rules/plan-acceptance-gates.md @@ -0,0 +1,116 @@ +# Atomic-Plan Acceptance Gates (G1 through G6) + +This rule governs the acceptance-gate rules the plan validator applies to the shell commands an atomic plan states as acceptance conditions. It exists because a plan can state an acceptance condition that cannot fail: a coverage argument that collects no data, or a search for a literal that returns zero matches whatever the executor does. Such a condition reads as a verification step and gates nothing (issue #486). + +The rules are enforced by `scripts/dev_tools/plan_gate_discrimination.py` and by the TypeScript parity port at `extensions/drm-copilot/src/lib/validate/plan-gate-discrimination.ts` with its shared-predicate module `plan-gate-rules.ts`, both fed by the command extractor (`scripts/dev_tools/plan_gate_commands.py` and `extensions/drm-copilot/src/lib/validate/plan-gate-commands.ts`). Enforcement is validator logic plus this prose file. No JSON Schema is authored, imported, or read. + +## Scope of Invocation — no grandfathering or exemption mechanism + +The plan validator only ever runs against the single artifact it is pointed at. No CI job, test, or scheduled task sweeps the committed plan corpus, and none is added by this feature. A pre-existing plan that would produce a finding is therefore never evaluated unless someone deliberately points the validator at it. + +That scope is the argument against a grandfathering list, an exemption marker, a per-plan suppression comment, and an allowlist file. Each of those mechanisms exists to protect an existing corpus from a newly added sweep. With no sweep there is nothing to protect, and the mechanism would add a suppression surface whose only reachable use is to silence a finding on the plan currently being authored — which is precisely the case the gate exists to report. + +The consequence is that adding a rule to this set is cheap in migration cost and expensive in authoring cost. Weigh a new rule on its false-positive rate at authoring time, not on how many committed plans it would have flagged. + +## Rule Table + +Every finding string begins with the square-bracketed `P#-T#` identifier of the task the command is attributed to, and renders the offending value or literal between backticks. + +| Rule | Condition | Shipped severity | +| --- | --- | --- | +| **G1** | A non-placeholder `--cov` value whose text, truncated at the first `::`, ends with `.py`. A `.py` suffix proves a filesystem path, which `coverage.py` rejects; the check is context-free and needs no repository lookup. | **Blocking** | +| **G2** | A `--cov` value containing a path separator whose text plus `.py` is a tracked file. The tracked sibling names the intended module exactly, so the dotted remedy is known. | **Blocking** | +| **G3** | A `--cov` value containing a path separator that resolves to neither a tracked file plus `.py` nor a tracked directory. Data collection is unknown rather than provably absent. | **Warning** | +| **G4** | A `--cov` value supplied space-separated (`--cov <value>`) rather than with `=`. The ambiguous form can bind the following positional argument. Independent of resolvability, so it is reported for every value. | **Warning** | +| **G5** | A checkable search literal that is absent from the tracked tree **and** not quoted in the plan document outside the command span it was read from. | **Warning** (see below) | +| **G6** | A checkable search literal absent from every single line of a tracked file but present in that file's sliding-window join of adjacent lines. A line-oriented search returns zero matches. | **Warning** | + +G1 through G4 form a cascade over each `--cov` value: the value is decided once, so a value G1 rejects is never additionally reported by G2 or G3. G4 is evaluated independently of the cascade because the ambiguous form is a defect whatever the value resolves to. G6 is evaluated before G5, because cross-line presence falsifies G5's tree-absence claim. + +G1 and G4 are context-free and run on every invocation. G2, G3, G5, and G6 require a repository seam; with no context supplied they do not run, and the Blocking list is byte-identical to the pre-change output for the same text. + +### Attribution window + +A command span is attributed to the current `P#-T#` when it sits on a task line or on a following line that is not itself a task line and is not separated from the task line by a Markdown ATX heading. A span in the document preamble, in a phase preamble, or after an intervening heading belongs to no task and is dropped rather than reported. A span that belongs to no task cannot be reported against one. + +### Graceful degradation + +A repository seam that raises, or that reports a non-zero exit, causes G2, G3, G5, and G6 to be skipped. No finding is produced and no exception escapes the evaluation entry point. A validation run must never fail because the repository could not be queried. + +## Severity Decisions + +### G5 — fixed by the corpus measurement and by nothing else + +The shipped G5 severity was not chosen by argument. It was fixed by a pre-declared rule applied to a measurement over the committed plan corpus: Blocking if and only if the total G5 finding count is greater than zero **and** the recorded false-positive count is zero; otherwise Warning. + +The measurement is recorded in `docs/features/active/2026-08-17-reject-unfalsifiable-acceptance-gates-in-atomic-plans-486/evidence/qa-gates/g5-corpus-measurement.2026-08-20T12-02.md`. It scanned 166 plan files, evaluated 100 candidate literals, and produced a total G5 finding count of 0. A zero false-positive count over zero findings measures nothing, so the first conjunct failed and **G5 ships as a Warning**. + +The zero count is a property of the corpus, not a defect in the measurement. Every committed plan is a tracked file, so a fixed-string search for a literal quoted inside a committed plan always finds at least that plan itself, and the tree-absence condition holds for no committed candidate. The measurement artifact records the four checks that established this (non-vacuous enumeration, a working repository seam, a self-hit on every sampled lookup, and predicate-order equivalence with the shipped rule). + +The rule remains meaningful for its intended use. The validator runs against a single plan artifact at authoring time, when that plan is typically uncommitted and therefore untracked, so its own text does not satisfy the tracked-tree presence test. The plan-quotation condition is what exonerates a literal the plan instructs the executor to create. + +A later feature may revisit the severity, but only against a fresh measurement taken the same way. The severity is a single constant in each runtime (`G5_SEVERITY`), and a parity test asserts the two constants agree. + +### G6 — ships as a Warning + +The Blocking argument for G6 is real and is preserved here rather than discarded. A literal present only across a line wrap is *provably* unmatched by a line-oriented search: the tracked evidence shows the phrase exists in the file yet matches no single line, so the assertion is known to return zero matches. That is a stronger evidential position than G3, which only reports that resolution is unknown, and it is comparable to G1, which is Blocking. + +G6 nonetheless ships as a Warning because of a residual false-positive case the rule cannot distinguish. The window join is computed over the file's committed text at `HEAD`. When the plan's own task is what rewrites that file so the phrase lands on one line, the pre-change committed text legitimately wraps the phrase and the post-change text does not. G6 then reports a search that will match after the task runs. The plan-quotation exoneration catches the common form of this case, but only when the plan quotes the literal contiguously in prose outside the command span; a plan that paraphrases the intended edit is still reported. + +Rejecting such a plan would block a correct plan on evidence about a state the plan is about to change. Surfacing the finding without failing the gate gives the author the same information at no such cost. Reclassifying G6 as Blocking requires first eliminating that case, for example by evaluating the window join against the working tree rather than `HEAD`. + +### The G6 sliding window is four adjacent non-blank lines + +The window size is fixed at four adjacent non-blank lines. Blank lines are removed before windowing, and one window is emitted per start position, so the boundary is exact: two lines further apart than the window size never appear in the same join. The size is recorded here rather than left implicit so that a later feature can revise it against measured wrap-depth data instead of re-deriving it. + +## Checkable-Literal Definition and the Placeholder Guard + +G5 and G6 apply only to a *checkable* literal. The specification defines a checkable literal by two conditions: the command carries the fixed-string flag `-F`, or the pattern contains none of the regular-expression metacharacters `. * [ ] ^ $ \ ( ) { } | + ?`. That condition is conservative in POSIX BRE, POSIX ERE, PCRE, and the Rust regex dialect simultaneously, so no dialect-selection logic is required. + +The shipped predicate **extends** that definition with a third condition, and the extension is deliberate: a pattern operand containing any placeholder or interpolation marker is never checkable, even when `-F` is supplied. The markers are `<`, `>`, `${`, `$(`, and `%` — the same set the coverage rules use to skip a placeholder `--cov` value. + +The guard exists because a command span whose operand is a placeholder was never intended to be executed verbatim. It documents a command *shape*, so it states no real acceptance assertion, and the resolvability of a placeholder operand is not decidable. Without the guard, every plan that documents a command shape using a placeholder operand receives a G5 finding. + +### Known false-negative class + +The guard is purely textual, so it fires on any pattern containing a placeholder character in any role. A literal that uses `<`, `>`, or `%` as an ordinary character — a TypeScript generic, a comparison operator, a version constraint, a percentage, an HTML or XML tag — is therefore skipped and can never produce a G5 or G6 finding, however unfalsifiable the assertion actually is. + +This is a false-negative class, not a defect to be silently tolerated: it is the cost side of the trade recorded below, and a later feature that narrows the guard must re-measure the false-positive side before doing so. Narrowing candidates include restricting the markers to bracket *pairs* enclosing an identifier-like token, or to the interpolation forms `${` and `$(` plus `%NAME%`, rather than treating every bare `<`, `>`, and `%` as a marker. + +### Preflight measurement that fixed the trade + +The trade was settled by measurement, not by preference. Across the 164-plan corpus examined at preflight, the placeholder guard suppressed exactly three pattern operands and suppressed zero additional findings: + +1. An **angle-bracketed placeholder** inside a documented `git grep` command shape. This is the guard's intended target: the command was written to show a shape, not to be run. +2. A **TypeScript generic** of the `warnings?: ReadonlyArray<string>` shape. Its own plan quotes the token contiguously in prose, so the plan-quotation condition would have exonerated it regardless; the guard changed nothing for this operand. +3. A **version constraint** of the `Node >=18` shape. The token is present in the tracked tree, so the tree-absence condition never held and no finding would have been produced; the guard again changed nothing. + +Only the first operand was suppressed by the guard in a way that altered the outcome, and it is the case the guard is for. The other two were already exonerated by conditions the guard does not touch. Against that, removing the guard would have produced a finding on every plan that documents a placeholder-bearing command shape. The measured cost of the guard on this corpus is therefore zero suppressed true positives. + +## Message Formatting — no `repr()`, no `!r`, no `pythonRepr` + +Every gate message renders the offending coverage value or search literal **between backticks**, in both runtimes, with no surrounding quote characters supplied by a formatting helper. + +The following are prohibited in gate messages: + +- Python `repr()` and the `!r` conversion in an f-string. +- Any `pythonRepr` helper on the TypeScript side. + +The reason is byte-identity across the two runtimes. Python's `repr` selects its quote character based on the value's contents, switching to double quotes when the value contains a single quote, while the TypeScript `pythonRepr` helper used elsewhere in this repository always single-quotes. A value carrying an apostrophe would therefore render differently in the two runtimes, and the parity requirement would fail on exactly the class of value a maintainer is most likely to encounter in a path or a prose literal. Backtick delimiting has no content-dependent behaviour and needs no helper. + +The prohibition is enforced by tests, not only by prose: a parity test asserts the Python gate module contains neither `!r` nor `repr(`, a companion test asserts no `pythonRepr(` call appears in any of the three TypeScript gate modules, and the parity fixture set includes an apostrophe-bearing `--cov` value and an apostrophe-bearing search literal whose expected strings are asserted identically in both runtimes. + +## Authoring Guidance for Plan Authors + +- Express coverage targets as importable dotted names (`--cov=scripts.dev_tools.module`), never as filesystem paths, and always with the `=` form. +- Where an acceptance condition is a search, assert a short, single-line, non-interpolated token that the plan quotes verbatim. +- Prefer a named test over a phrase search whenever a test can carry the assertion. + +`.claude/skills/atomic-plan-contract/SKILL.md` carries the authoring-side statement of this guidance and cross-references this file. + +## Enforcement + +- `scripts/dev_tools/plan_gate_commands.py` extracts task-attributed command candidates; `scripts/dev_tools/plan_gate_discrimination.py` evaluates G1 through G6 and returns the two severity channels. +- `scripts/dev_tools/validate_orchestration_artifacts.py` routes the existing `plan` artifact type through the two-channel entry point, prints each Warning to stderr prefixed with `PLAN GATE WARNING: `, and derives its exit code from the error channel alone. No new flag, option, or artifact type is added. +- The TypeScript parity port is dispatched from `extensions/drm-copilot/src/lib/validate/orchestration-artifacts.ts` for the existing `plan` artifact type. The MCP `validate_orchestration_artifacts` input-schema property-key set is unchanged; Warnings surface on an optional `warnings` field that is absent when there are none. +- `.claude/hooks/validate-planner-output.ps1` is not modified by this rule and carries no part of its enforcement. diff --git a/.claude/rules/powershell.md b/.claude/rules/powershell.md index d690bd08e..ce86d6ec3 100644 --- a/.claude/rules/powershell.md +++ b/.claude/rules/powershell.md @@ -61,7 +61,7 @@ Introduce the smallest seam that enables reliable mocking. Apply these options i - Mock sparingly; prefer real code paths. - No external dependencies in unit tests. - Line coverage must remain >= 85% across all tiers (T1–T4) per `.claude/rules/quality-tiers.md`. -- Branch coverage must remain >= 75% across all tiers (T1–T4). +- Pester reports **command (instruction) coverage and line coverage only**. The uniform line-coverage threshold (>= 85% per `.claude/rules/quality-tiers.md`) applies. Branch coverage is not measurable by Pester for PowerShell; there is no PowerShell branch-coverage gate. This removes an unevaluable threshold, not a measurement obligation: PowerShell production files remain in the coverage denominator per the Coverage Exclusion Policy in `.claude/rules/general-unit-test.md`, and command coverage is reported for information only, with no threshold attached. - Coverage regression on changed lines is a blocking finding. ### Deterministic Test Requirements diff --git a/.claude/rules/quality-tiers.md b/.claude/rules/quality-tiers.md index 1674c71c9..28209fc80 100644 --- a/.claude/rules/quality-tiers.md +++ b/.claude/rules/quality-tiers.md @@ -22,7 +22,7 @@ This rule defines the T1–T4 module rigor tier system used by all CI gates in t ## Uniform-vs-Tier-Dependent Gate Matrix -Per Authoritative Decision #2, line and branch coverage thresholds are uniform across all tiers. Other gates remain tier-dependent. +Per Authoritative Decision #2, line and branch coverage thresholds are uniform across all tiers. The line threshold applies to every coverage language; the branch threshold applies to languages whose coverage tooling measures branch coverage. Other gates remain tier-dependent. ### Uniform across all tiers (T1–T4) @@ -31,7 +31,7 @@ Per Authoritative Decision #2, line and branch coverage thresholds are uniform a - Type errors: 0. - Architecture violations: 0. - Line coverage: >= 85%. -- Branch coverage: >= 75%. +- Branch coverage: >= 75% for languages whose coverage tooling measures branch coverage. PowerShell (Pester) and bash (kcov) are exempt from this threshold because neither tool measures branch coverage; no branch-coverage gate applies to them. - No regression on changed lines. ### Tier-dependent @@ -48,4 +48,4 @@ Per Authoritative Decision #2, line and branch coverage thresholds are uniform a ## Rationale (uniform coverage thresholds) -High test coverage is a fundamental quality-control design choice that enables autonomous agentic development and trust in the work product. For that reason, line coverage >= 85% and branch coverage >= 75% apply uniformly across T1–T4; tier-specific lower coverage floors are not used in this repository. +High test coverage is a fundamental quality-control design choice that enables autonomous agentic development and trust in the work product. For that reason, line coverage >= 85% applies uniformly across T1–T4 to every coverage language, and branch coverage >= 75% applies uniformly across T1–T4 to every language whose coverage tooling measures branch coverage; tier-specific lower coverage floors are not used in this repository. The branch threshold is not applied to PowerShell or bash because Pester and kcov do not measure branch coverage. That exemption is a capability limit on an unevaluable threshold, not a licence to exclude files from measurement: PowerShell and bash production files remain in the coverage denominator under the Coverage Exclusion Policy in `.claude/rules/general-unit-test.md`. diff --git a/.claude/rules/typescript.md b/.claude/rules/typescript.md index dde9cdbee..337e27a0e 100644 --- a/.claude/rules/typescript.md +++ b/.claude/rules/typescript.md @@ -25,21 +25,50 @@ Run the toolchain in order: format → lint → type-check → test. Restart fro - **Domain types**: Model domain concepts with interfaces/types that encode invariants. Prefer discriminated unions for state machines. - **Naming**: `PascalCase` for classes, interfaces, enums, and type aliases. `camelCase` for functions, methods, variables, and object properties. No `I` prefix on interfaces. - **File naming**: Prefer kebab-case filenames (e.g., `user-session.ts`, `task-runner.ts`). -- **Separation of concerns**: Keep pure logic separate from VS Code extension APIs, filesystem/network I/O, and UI wiring. +- **Separation of concerns**: Keep pure logic separate from Office.js, Microsoft Graph SDK, and other host-bound APIs, filesystem/network I/O, and UI wiring. - **Error handling**: Fail fast with clear errors. Avoid catch-all `catch (e)` without rethrowing or adding context. - **Dependencies**: Do not add new runtime dependencies unless explicitly approved. +## ESLint Stack + +- Require `typescript-eslint` strict-type-checked + stylistic-type-checked rule sets. +- Enable type-aware parsing (`parserOptions.project = true`). +- Required plugins: `eslint-plugin-office-addins`, `eslint-plugin-promise`, `eslint-plugin-security`, `eslint-plugin-import`. +- Error-level rules: `no-floating-promises`, `no-misused-promises`, all `no-unsafe-*`. +- Add a `no-restricted-syntax` rule banning `Date.now`, `setTimeout`, `setInterval`, and `Math.random` outside an explicit infrastructure allowlist. + ## Testing Standards - Use **Jest** as the test framework. - Name test files `*.test.ts`. -- Unit tests must not require the VS Code extension host. +- Unit tests must not require the Outlook host runtime. - Follow Arrange–Act–Assert structure. - Each test targets one behavior. - Use `jest.spyOn` or `jest.mock` for targeted mocking; reset mocks with `afterEach(() => { jest.resetAllMocks(); })`. - No external dependencies (network, filesystem temp files, external processes) in unit tests. - Avoid snapshot tests unless stable and intentional. -- Repository-wide line coverage must remain >= 80%. -- Any new module, class, or method must reach >= 90% coverage. -- Coverage command: `npm run test:unit:coverage` +- Coverage thresholds follow the uniform tier rule defined in `.claude/rules/quality-tiers.md`: line coverage >= 85% and branch coverage >= 75% across all tiers (T1–T4). +- Coverage command: `npm run test:unit:coverage` (the root `package.json` script runs `node run-jest.cjs --coverage`). - Coverage regression on changed lines is a blocking finding. +- Interface/type-only files with no executable behavior — files consisting solely of `interface` or `type` declarations — may be omitted from coverage measurement. Such files legitimately report 0% executable coverage. This is a clarification only; it does not lower any coverage threshold. + +## Architecture Boundaries + +Layer rules and the No-COM architecture assertions are defined in `.claude/rules/architecture-boundaries.md`. The TypeScript enforcement tool is `dependency-cruiser` with configuration file `.dependency-cruiser.cjs`. + +## Property-Based and Mutation Testing + +- `fast-check` provides property-based tests; T1 and T2 modules require >= 1 property test per pure function. +- `StrykerJS` provides mutation testing; T1 modules require mutation score >= 75%. +- Both run in pre-merge or nightly pipelines per `general-code-change.md`. + +## Golden Tests + +- T1 classifier modules require golden-output snapshots tested against a versioned corpus. +- The general guidance to avoid snapshot tests unless stable and intentional remains in force for all other scenarios; classifier-output and schema-evolution snapshots are explicitly permitted when versioned. + +## Runtime Determinism + +- `Date`, `Math.random`, and `setTimeout` access must flow through an injected `Clock` / `Random` interface. +- Tests use Jest fake timers (`jest.useFakeTimers()`). +- Prefer `await flushPromises()` over `setTimeout(0)` for awaiting micro-tasks. diff --git a/.claude/settings.json b/.claude/settings.json index 67602e2ef..3d1b17910 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -64,6 +64,7 @@ "Skill(execute-hard-lock *)", "Skill(identify-session-id *)", "Skill(show-my-agent-tree *)", + "Skill(mermaid-diagram *)", "Edit(/.claude/skills/execute-hard-lock/**)", "Edit(/.claude/skills/feature-review-workflow/**)", "Edit(/.claude/skills/csharp-qa-gate/**)" @@ -170,6 +171,10 @@ { "type": "command", "command": "pwsh -NoProfile -File .claude/hooks/enforce-discovery-artifact-gate.ps1" + }, + { + "type": "command", + "command": "pwsh -NoProfile -File .claude/hooks/enforce-mermaid-validation.ps1" } ] }, diff --git a/.claude/skills/atomic-plan-contract/SKILL.md b/.claude/skills/atomic-plan-contract/SKILL.md index da8106f0f..f7cb98e2b 100644 --- a/.claude/skills/atomic-plan-contract/SKILL.md +++ b/.claude/skills/atomic-plan-contract/SKILL.md @@ -157,6 +157,21 @@ Before a plan can be treated as approved: - reject the plan if that validator exits non-zero, - do not treat human-readable summaries as a substitute for validator success. +The same validator call also applies the acceptance-gate rules G1 through G6 defined in `.claude/rules/plan-acceptance-gates.md`. Those rules report acceptance conditions that cannot fail — a coverage argument that collects no data, or a search for a literal that returns zero matches whatever the executor does. They run automatically on the existing `plan` route with no additional flag. Blocking findings appear in the validator's error output and fail the gate; Warnings are surfaced without failing it, prefixed with `PLAN GATE WARNING: ` on the CLI and carried on the optional `warnings` field of the MCP result. Read that rule file before authoring acceptance conditions. + +## Wrap-Tolerant Assertion Authoring (Mandatory) + +An acceptance condition must be able to fail. A condition whose command returns the same result whatever the executor does verifies nothing, however precise it reads. Author every acceptance condition in a wrap-tolerant form: one that survives line wrapping and shell quoting in the file it asserts against. + +Rules: + +- **Prefer a named test over a phrase search.** When a test can carry the assertion, name the test and its node ID and assert its pass count. A test node ID is stable under reformatting; a prose phrase is not. Reserve searches for cases where no test can express the condition. +- **Single-line token rule.** Where a search is unavoidable, assert a short, single-line, non-interpolated token that the plan quotes verbatim. A multi-word phrase drawn from prose is wrap-fragile: once the target file reflows, the phrase spans two lines and a line-oriented search returns zero matches even though the text is present. Rule G6 in `.claude/rules/plan-acceptance-gates.md` reports this case. +- **No placeholders in an asserted token.** A token containing `<`, `>`, `${`, `$(`, or `%` is treated as a documented command shape rather than a real assertion and is skipped by the gate, so it gates nothing. Substitute the concrete value. +- **Dotted coverage-argument form.** Coverage assertions must name an importable dotted module, for example `--cov=scripts.dev_tools.plan_gate_discrimination`. The filesystem-path spellings `--cov=scripts/dev_tools/module.py` and `--cov=scripts/dev_tools/module` collect no data, so a coverage threshold asserted against them cannot fail. Rules G1 through G3 report those spellings. +- **Use the `=` form, not the space-separated form.** `--cov <value>` can bind the following positional argument. Rule G4 reports it. +- **Quote what the task will create.** When an asserted literal does not yet exist in the tree, quote the exact literal in the plan prose outside the command span. The gate reads that quotation as the executor's instruction and exonerates the assertion; a paraphrase does not. + ## Plan-Path Continuity Contract (Mandatory) When a caller provides an explicit target plan file path (for example `${plan-path}` or `${file}`): diff --git a/.claude/skills/csharp-qa-gate/SKILL.md b/.claude/skills/csharp-qa-gate/SKILL.md index 3b0703cc7..0d3b005a0 100644 --- a/.claude/skills/csharp-qa-gate/SKILL.md +++ b/.claude/skills/csharp-qa-gate/SKILL.md @@ -67,8 +67,6 @@ Persist toolchain output according to `evidence-and-timestamp-conventions`: - store post-change outputs under `<FEATURE>/evidence/qa-gates/<timestamp>/`, - use ISO-8601 UTC timestamps in folder names. -- For steps 2 and 3, capture an MSBuild file log (`/fl "/flp:logfile=<path>;verbosity=normal"`) and record in the evidence artifact that the log contains **zero** occurrences of `Skipping target "CoreCompile"`. A step that reports exit 0 with a non-zero skip count compiled nothing and is **unverified**, not passed. - This location is canonical per evidence-and-timestamp-conventions and is not overridable. See `.claude/skills/evidence-and-timestamp-conventions/SKILL.md` for the canonical evidence path authority. diff --git a/.claude/skills/evidence-and-timestamp-conventions/SKILL.md b/.claude/skills/evidence-and-timestamp-conventions/SKILL.md index a2f7b6589..464c15f40 100644 --- a/.claude/skills/evidence-and-timestamp-conventions/SKILL.md +++ b/.claude/skills/evidence-and-timestamp-conventions/SKILL.md @@ -110,6 +110,19 @@ When evidence artifacts are used for automated checking or plan reconciliation, - `Command: <exact command>` - `EXIT_CODE: <int>` +One optional field may also be declared: +- `ExpectedExitCode: <int>` — the exit code the gate is expected to produce. + +Rules for the optional expectation field: +- The spelling is exact and case-sensitive: `ExpectedExitCode`. `expectedexitcode` and `Expected Exit Code` do not match the accept-list and are discarded as unrecognized rows. +- The value is a single integer. A leading sign is accepted and no range check is applied; the value is used for an equality comparison only. +- When the field is absent the expectation defaults to `0`, so every artifact that omits it keeps its existing result. Writing `ExpectedExitCode: 0` explicitly renders identically to omitting the field. +- A present but non-integer value (including an empty value) makes the WHOLE artifact `unparseable`. An unparseable artifact is dropped by the collector filter, so a typo in the expectation removes the row from the PR body rather than degrading it to `fail`. +- When the field is duplicated, the FIRST occurrence wins in both the Python and the TypeScript parser; later occurrences are ignored. +- The field is per-FILE, not per-gate: one artifact carries exactly one expectation, so an artifact recording several gates cannot express a different expectation for each. Record a gate that needs a non-zero expectation in its own artifact file. + +A gate whose observed `EXIT_CODE` equals its declared expectation is normalized to `pass`. The observed exit code is still displayed, and the rendered row additionally carries ` - Expected EXIT_CODE: <int>` between the `EXIT_CODE` and `Normalized result` lines when the expectation is non-zero. + ### Baseline Evidence Output Summary (Required) For baseline evidence artifacts stored under `evidence/baseline/`, include an output summary in addition to the schema fields above: diff --git a/.claude/skills/feature-promotion-lifecycle/SKILL.md b/.claude/skills/feature-promotion-lifecycle/SKILL.md index a9a905c44..7f79f6abc 100644 --- a/.claude/skills/feature-promotion-lifecycle/SKILL.md +++ b/.claude/skills/feature-promotion-lifecycle/SKILL.md @@ -68,8 +68,14 @@ When orchestrator routing selects short path, promotion/folder initialization st - `${feature-folder}/issue.md` contains an explicit `## Acceptance Criteria` section - `${feature-folder}/spec.md` does not exist - `${feature-folder}/user-story.md` does not exist +- the promoted record under `docs/features/potential/promoted/` is still present (see 4b) - if any check fails, stop and remediate before planning +4b) Verify the promoted record was retained after `new_active_feature_folder`: +- the promoted file the earlier `potential_to_issue` step reported as its `destination_path` must still exist under `docs/features/potential/promoted/` +- `new_active_feature_folder` COPIES a promoted source into the active folder as `issue.md`; it MOVES a source resolved from `docs/features/potential/` directly. An absent promoted record after a promoted-source run is a defect, not expected cleanup (issue #487). +- this check applies to every work mode, not only `minor-audit` + 5) Delegate minimal-audit plan creation to `atomic_planner` with directive: - `DIRECTIVE: MINIMAL-AUDIT PLAN REQUIRED` diff --git a/.claude/skills/feature-review-workflow/SKILL.md b/.claude/skills/feature-review-workflow/SKILL.md index 17c7fc3e5..dad9f463e 100644 --- a/.claude/skills/feature-review-workflow/SKILL.md +++ b/.claude/skills/feature-review-workflow/SKILL.md @@ -108,10 +108,10 @@ If the branch diff modifies any path matching `.github/workflows/**`, `scripts/b - Python: `poetry run pytest --cov` → artifact: `artifacts/python/lcov.info` - PowerShell: `mcp__drm-copilot__run_poshqc_test` → artifact: `artifacts/pester/powershell-coverage.xml` - C#: `dotnet test --collect:"XPlat Code Coverage"` → artifact: `artifacts/csharp/coverage.xml` - - Coverage thresholds (uniform tier rule per quality-tiers.md): - - New code files (added in this feature): line coverage >= 85% and branch coverage >= 75%. Flag as FAIL otherwise. - - Modified files (changed but previously existing): line coverage >= 85%, branch coverage >= 75%, and no regression on changed lines relative to baseline. Flag as FAIL otherwise. - - Repo-wide per language: line coverage >= 85% and branch coverage >= 75%. Flag as FAIL otherwise. + - Coverage thresholds (uniform tier rule per quality-tiers.md). The branch threshold applies only to branch-capable languages — TypeScript, Python, and C#. PowerShell is a coverage language and is fully subject to the line threshold and the no-regression requirement, but Pester measures command (instruction) coverage and line coverage only, so no branch percentage exists to evaluate and no branch threshold applies to it (see `.claude/rules/powershell.md`). Do not flag a missing PowerShell branch figure as FAIL: + - New code files (added in this feature): line coverage >= 85%, and branch coverage >= 75% for branch-capable languages. Flag as FAIL otherwise. + - Modified files (changed but previously existing): line coverage >= 85%, branch coverage >= 75% for branch-capable languages, and no regression on changed lines relative to baseline. Flag as FAIL otherwise. + - Repo-wide per language: line coverage >= 85%, and branch coverage >= 75% for branch-capable languages. Flag as FAIL otherwise. - If coverage artifacts already exist from the executor run, inspect them instead of re-running. - If no coverage artifact exists for a language that has changed files, flag as FAIL — coverage verification is mandatory for all languages with changed files. - Run the smallest relevant subset first when the repo policy permits it. diff --git a/.claude/skills/mermaid-diagram/SKILL.md b/.claude/skills/mermaid-diagram/SKILL.md new file mode 100644 index 000000000..80f10cfbb --- /dev/null +++ b/.claude/skills/mermaid-diagram/SKILL.md @@ -0,0 +1,184 @@ +--- +name: mermaid-diagram +description: 'Generate, validate, and render Mermaid diagrams (flowchart, sequence, class, state, ER, C4, gantt, pie). Use when asked to create, edit, fix, or visualize a diagram, write a .mmd file, or embed a mermaid fence in Markdown. Bundles per-type syntax references and the generate-validate-render workflow enforced by the enforce-mermaid-validation hook.' +--- + +# Mermaid Diagram + +Authoring workflow for Mermaid diagrams on the Claude runtime. The constraints — file conventions, +the validation mandate, the managed-diagram rule, and the opt-out marker — are in +`.claude/rules/mermaid.md`. This skill carries the workflow and the generation recipes. + +Pinned Mermaid documentation version: **11.17.0**. + +## Workflow: Generate, Validate, Render + +1. **Determine the diagram type.** Pick from the per-type references below. When the type is + unfamiliar, read its reference file before generating; when the reference does not answer the + question, `WebFetch` the pinned documentation page (see [Syntax References](#syntax-references)). +2. **Generate the syntax.** Follow the reference's first-line keyword form and its arrow token set. + Keywords are case-sensitive. +3. **Write the diagram.** A standalone diagram goes in a `.mmd` file; a diagram that belongs to + prose goes in a fenced ` ```mermaid ` block in that document. +4. **Validate.** The `Write` is gated automatically by + `.claude/hooks/enforce-mermaid-validation.ps1`. A deny names the defect class, the line number, + and points back here. To check before writing, call the validator directly: + + ```powershell + Import-Module ./.claude/lib/mermaid/MermaidValidation.psm1 -Force + Test-MermaidDiagram -Content $diagramText + ``` + + The result carries `Verdict` (`Valid`, `Invalid`, `NotJudged`), `DiagramType`, `Findings` (each + with `Class`, `Line`, `Message`), and `Warnings`. +5. **Render** per [Rendering](#rendering). Rendering is a workflow step, never something the hook + does: a hook is a non-interactive subprocess whose stdout belongs to the hook protocol. + +### What "validated" means here + +The gate rejects the defect classes listed in `.claude/rules/mermaid.md`. It does not parse and it +cannot prove a diagram renders. Do not report a diagram as "validated" without that qualifier; say +the structural gate accepted it. Semantic errors — an undefined node reference, a malformed gantt +date, an invalid `classDef` property — pass the gate and still fail to render. + +## Generation Recipes + +Eight recipes, one per generation intent of the Copilot `@mermaid-chart` participant. Each names +the source to read, the diagram type to emit, and the shape that survives review. + +### 1. Diagram from code (`/generate_diagram_from_code`) + +Read the entry point and follow control flow outward one level at a time. Emit a `flowchart` +whose nodes are functions or modules and whose edges are calls. Keep node labels to the symbol +name; put qualifiers in a quoted label rather than in the identifier. Stop at the first boundary +the reader does not need (framework internals, third-party libraries) and mark it as one node. + +### 2. Execution sequence (`/generate_execution_sequence`) + +Emit a `sequenceDiagram`. One `participant` per process, service, or object that owns state; +messages in call order; the message text after the first colon carries the payload summary. Use +`-->>` for returns and `->>` for calls so the direction reads without the labels. Reserve `activate` +and `deactivate` for lifetimes the reader must see; they add noise otherwise. + +### 3. ER diagram (`/generate_er_diagram`) + +Read the schema, ORM models, or migration files. Emit an `erDiagram`. One entity per table, the +cardinality token pair chosen from the reference table, and the relationship label as the verb the +domain uses. Include an attribute block only for the columns that carry the relationship (keys) or +that the reader must see; a full column dump defeats the diagram. + +### 4. Cloud or CI/CD architecture (`/generate_cloud_architecture_diagram`) + +Read the infrastructure-as-code files and the workflow definitions. Emit a `flowchart` with one +`subgraph` per environment, account, or region boundary, and `-.->` for asynchronous or +event-driven edges against `-->` for synchronous ones. State the direction convention in a comment +so the next reader keeps it. + +### 5. Docker architecture (`/generate_docker_diagram`) + +Read the Dockerfiles and the compose file. Emit a `flowchart` with one node per service, one +`subgraph` per compose network, and edges labelled with the published or internal port. Show +volumes as nodes only when a volume is shared between services. + +### 6. C4 top-down architecture (`/generate_c4_topdown_architecture`) + +Emit `C4Context` for the system landscape, then `C4Container` for the chosen system, then +`C4Component` for the chosen container: one diagram per level, not one diagram with three levels. +Keywords carry a capital `C4`. Relationships use the `Rel(...)` call form rather than arrow tokens. +See `references/c4.md`. + +### 7. Code ownership (`/analyze_code_ownership`) + +Read `CODEOWNERS`, or derive ownership from directory structure when no such file exists. Emit a +`flowchart` with one `subgraph` per owning team and the owned directories as nodes. When ownership +is derived rather than declared, say so in the diagram title; an inferred ownership map presented +as authoritative is worse than none. + +### 8. Dependency or security visualisation (`/generate_dependency_diagram`) + +Read the manifest and lock files. Emit a `flowchart` for the dependency graph, direct dependencies +at the first level and transitive ones only where they matter to the question being asked. For a +security view, mark the affected node with a `classDef` and state the advisory identifier in the +label. + +## Rendering + +`Artifact` and `SendUserFile` are harness-dependent and are absent from some sessions. Take the +first available path: + +1. **`Artifact` available.** Publish a Markdown artifact containing the ` ```mermaid ` fence. This + is the preferred path: no CSP handling and no theme handling, unlike an HTML artifact. +2. **Else `SendUserFile` with `display: "render"` available.** Use it. +3. **Else** state that the diagram was written to its path and name the viewing route: the Mermaid + Chart VS Code extension auto-previews `.mmd` and `.mermaid` files, the built-in VS Code Markdown + preview renders fenced blocks, and GitHub renders ` ```mermaid ` fences natively in Markdown, + pull requests, and issues. + +Never claim a diagram was rendered when only path 3 was taken. Say where it was written and how to +view it. + +## Opt-Out Marker + +To quote invalid Mermaid deliberately, place the exact HTML comment on the line immediately before +the fence: + +```text +<!-- mermaid-validator: ignore --> +``` + +The marker suppresses validation for exactly that one block, must have no intervening line before +the fence, applies only to Markdown fences, and never suppresses the managed-diagram guard. Full +rules are in `.claude/rules/mermaid.md`. + +## Syntax References + +Per-type references under `references/`, pinned to Mermaid 11.17.0: + +| File | Covers | +| --- | --- | +| `references/flowchart.md` | `flowchart`, `graph`, `flowchart-elk` | +| `references/sequence.md` | `sequenceDiagram` | +| `references/class.md` | `classDiagram`, `classDiagram-v2` | +| `references/state.md` | `stateDiagram-v2`, `stateDiagram` | +| `references/er.md` | `erDiagram` | +| `references/c4.md` | `C4Context`, `C4Container`, `C4Component`, `C4Dynamic`, `C4Deployment` | +| `references/gantt.md` | `gantt` | +| `references/pie.md` | `pie` | +| `references/other-types.md` | every remaining keyword of the pinned table | + +**`WebFetch` fallback.** The references are a snapshot, not the documentation. When a construct is +absent from them, or when a first-line keyword is not in the validator's allowlist, fetch the +pinned page and confirm the form before generating: + +- entry point: `https://mermaid.js.org/intro/syntax-reference.html` +- per-type pages: `https://mermaid.js.org/syntax/<type>.html` + +Confirming a keyword against the documentation is also the mechanism for updating +`.claude/lib/mermaid/MermaidGrammar.psm1` when Mermaid adds a diagram type: the validator warns +rather than blocks on an unknown keyword, so a warning is the signal to check and extend the table. + +## Out of Scope + +The VS Code extension mechanisms the Copilot instruction pack relies on are not reachable from a +Claude Code session. The full disposition table, one row per mechanism with its reason and its +replacement, is in `.claude/rules/mermaid.md` under "Out of Scope: The Non-Portable Extension +Mechanisms". In summary: the three LM tools, the sixteen `mermaidChart.*` command IDs, the +`@mermaid-chart` chat participants, and the Mermaid Chart cloud login/sync/review flows are not +ported; validation, generation, preview, and sync cooperation are ported by substitution to the +hook, these recipes, the rendering paths above, and the `id:` guard. Deep `mmdc`/Chromium +validation in CI, and retrofitting the existing repository Mermaid emitters through this validator, +are recorded follow-ups. + +## Worked Example + +```mermaid +--- +title: Generate, validate, render +--- +flowchart LR + A[Pick diagram type] --> B[Read references type page] + B --> C[Generate syntax] + C --> D{Structural gate} + D -->|deny with class and line| C + D -->|allow| E[Render or state the path] +``` diff --git a/.claude/skills/mermaid-diagram/references/c4.md b/.claude/skills/mermaid-diagram/references/c4.md new file mode 100644 index 000000000..5a972d9b7 --- /dev/null +++ b/.claude/skills/mermaid-diagram/references/c4.md @@ -0,0 +1,50 @@ +# C4 Diagram Syntax Reference + +Pinned to Mermaid **11.17.0**. Source: `https://mermaid.js.org/syntax/c4.html`. +When a construct is absent here, `WebFetch` that page and confirm the form before generating. + +## First-line keyword forms + +`C4Context`, `C4Container`, `C4Component`, `C4Dynamic`, `C4Deployment`. + +The capital `C4` is part of the keyword. `c4context` does not resolve, and the validator is +case-sensitive by design because Mermaid is. + +Emit one diagram per C4 level rather than one diagram spanning levels: context first, then the +container view of the chosen system, then the component view of the chosen container. + +## Statement forms + +C4 uses call-style statements, not arrow tokens. The validator therefore keyword-checks a C4 +diagram and does not judge its body. + +- Elements: `Person(alias, label, description)`, `Person_Ext(...)`, + `System(alias, label, description)`, `System_Ext(...)`, `SystemDb(...)`, `SystemQueue(...)`, + `Container(alias, label, technology, description)`, `ContainerDb(...)`, `ContainerQueue(...)`, + `Component(alias, label, technology, description)`. +- Boundaries: `Enterprise_Boundary(alias, label) { ... }`, `System_Boundary(...)`, + `Container_Boundary(...)`, `Boundary(alias, label, type)`. Braces are paired. +- Relationships: `Rel(from, to, label, technology)` plus the directional variants `Rel_U`, `Rel_D`, + `Rel_L`, `Rel_R`, and `BiRel(...)` for a two-way relationship. +- Layout: `UpdateLayoutConfig($c4ShapeInRow, $c4BoundaryInRow)`. Styling: + `UpdateElementStyle(alias, $bgColor, $fontColor, $borderColor)`, + `UpdateRelStyle(from, to, $offsetX, $offsetY)`. + +## Example + +```mermaid +C4Context + title System context for the order service + Person(customer, "Customer", "Places and tracks orders") + Enterprise_Boundary(company, "Retail company") { + System(orders, "Order service", "Accepts and tracks orders") + System(billing, "Billing service", "Charges cards and issues refunds") + SystemDb(orderdb, "Order store", "Durable order records") + } + System_Ext(psp, "Payment provider", "Third-party card processing") + Rel(customer, orders, "Places an order", "HTTPS/JSON") + Rel(orders, orderdb, "Reads and writes", "SQL") + Rel(orders, billing, "Requests a charge", "internal API") + Rel(billing, psp, "Authorises the card", "HTTPS") + UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="1") +``` diff --git a/.claude/skills/mermaid-diagram/references/class.md b/.claude/skills/mermaid-diagram/references/class.md new file mode 100644 index 000000000..c01227552 --- /dev/null +++ b/.claude/skills/mermaid-diagram/references/class.md @@ -0,0 +1,63 @@ +# Class Diagram Syntax Reference + +Pinned to Mermaid **11.17.0**. Source: `https://mermaid.js.org/syntax/classDiagram.html`. +When a construct is absent here, `WebFetch` that page and confirm the form before generating. + +## First-line keyword forms + +`classDiagram`, or the legacy-accepted `classDiagram-v2`. + +## Relation tokens + +A relation is composed as `<tail><line><head>`: + +| Token | Meaning | +| --- | --- | +| `<|--` / `--|>` | inheritance | +| `*--` / `--*` | composition | +| `o--` / `--o` | aggregation | +| `-->` / `<--` | association | +| `--` | link, solid | +| `..>` / `<..` | dependency | +| `..|>` / `<|..` | realization | +| `..` | link, dashed | + +Cardinalities are quoted and sit outside the token: `Customer "1" --> "0..*" Order`. +Generics use tilde runs: `List~T~`, and a tilde run is never an edge defect. + +## Structural conventions + +- A member block is `class <Name> { ... }` with `+`, `-`, `#`, `~` visibility prefixes; methods + carry `()`. Brackets and braces are structural in a class diagram, so every opener needs a closer. +- A member may also be declared inline: `Animal : +String name`. +- `namespace <Name> { ... }` groups classes. +- Annotations use `<<interface>>` / `<<abstract>>` on their own line inside the block or after the + class name. +- `classDef`, `cssClass`, `click`, `style`, `note`, `note for <Class>` are statement lines exempt + from the edge rules. +- Text after the first `:` on a relation line is the relation label and is free text. + +## Example + +```mermaid +classDiagram + direction LR + class Repository~T~ { + <<interface>> + +findById(id) T + +save(entity) void + } + class OrderRepository { + -connection + +findById(id) Order + +save(order) void + } + class Order { + +String id + +decimal total + +addLine(line) + } + Repository~T~ <|.. OrderRepository + OrderRepository ..> Order : returns + Order "1" --* "0..*" OrderLine : contains +``` diff --git a/.claude/skills/mermaid-diagram/references/er.md b/.claude/skills/mermaid-diagram/references/er.md new file mode 100644 index 000000000..52916bb88 --- /dev/null +++ b/.claude/skills/mermaid-diagram/references/er.md @@ -0,0 +1,56 @@ +# Entity Relationship Diagram Syntax Reference + +Pinned to Mermaid **11.17.0**. Source: `https://mermaid.js.org/syntax/entityRelationshipDiagram.html`. +When a construct is absent here, `WebFetch` that page and confirm the form before generating. + +## First-line keyword form + +`erDiagram`. + +## Cardinality tokens + +A relationship token is `<left><line><right>`. + +| Position | Options | +| --- | --- | +| left | `\|o` (zero or one), `\|\|` (exactly one), `}o` (zero or more), `}\|` (one or more) | +| line | `--` (identifying), `..` (non-identifying) | +| right | `o\|` (zero or one), `\|\|` (exactly one), `o{` (zero or more), `\|{` (one or more) | + +Common complete forms: `||--||`, `||--o{`, `}o--o{`, `}|--|{`, `|o..o|`, `}|..|{`. + +Word aliases are also accepted in place of the token: `one or zero`, `zero or more`, `only one`, +`1+`, `0+`, `many(0)`, `many(1)`, joined by `to` or `optionally to`. + +The relationship label follows the first `:` and is free text. + +## Structural conventions + +- An attribute block is `ENTITY { <type> <name> <key> "<comment>" }`. Braces are structural. +- Key markers are `PK`, `FK`, `UK`; several may be comma-separated. +- An entity name may be quoted when it is not identifier-shaped. +- `%%` comments and the statement keywords behave as in every other type. + +## Example + +```mermaid +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ ORDER_LINE : contains + PRODUCT }o--o{ ORDER_LINE : "appears in" + CUSTOMER { + string id PK + string email UK "lowercased on write" + string display_name + } + ORDER { + string id PK + string customer_id FK + decimal total + } + ORDER_LINE { + string order_id FK + string product_id FK + int quantity + } +``` diff --git a/.claude/skills/mermaid-diagram/references/flowchart.md b/.claude/skills/mermaid-diagram/references/flowchart.md new file mode 100644 index 000000000..3f9d312d6 --- /dev/null +++ b/.claude/skills/mermaid-diagram/references/flowchart.md @@ -0,0 +1,68 @@ +# Flowchart Syntax Reference + +Pinned to Mermaid **11.17.0**. Source: `https://mermaid.js.org/syntax/flowchart.html`. +When a construct is absent here, `WebFetch` that page and confirm the form before generating. + +## First-line keyword forms + +- `flowchart` followed optionally by a direction: `TB`, `TD`, `BT`, `LR`, `RL`. The direction is + optional and defaults to `TB`. +- `graph` with the same optional direction. Accepted and equivalent for the validator's purposes. +- `flowchart-elk` selects the ELK layout variant. +- A trailing `;` on the keyword line is accepted (`graph LR;`). + +## Edge tokens + +| Token | Meaning | +| --- | --- | +| `-->` | arrow | +| `---` | open link | +| `-.->` | dotted arrow | +| `-.-` | dotted open link | +| `==>` | thick arrow | +| `===` | thick open link | +| `~~~` | invisible link | +| `--o` | circle edge | +| `--x` | cross edge | +| `o--o`, `x--x`, `<-->` | bidirectional forms | + +Length variants extend the dash, dot, or equals run (`---->`, `====>`, `-...->`) and rank the edge +lower in layout. Text forms: `A -- text --> B`, `A -->|text| B`, `A -. text .-> B`, +`A == text ==> B`. + +## Node shapes + +`A[rect]`, `A(round)`, `A([stadium])`, `A[[subroutine]]`, `A[(cylinder)]`, `A((circle))`, +`A>asymmetric]`, `A{rhombus}`, `A{{hexagon}}`, `A[/parallelogram/]`, `A[\parallelogram alt\]`, +`A[/trapezoid\]`, `A(((double circle)))`. + +Brackets are structural in a flowchart, so every opener needs its closer. A bracket inside a quoted +label is content, not structure: `A["foo[bar](baz)"]` is valid. + +## Structural conventions + +- `subgraph <id> [<free-text title>]` opens a block; `end` closes it. A `direction` statement inside + a subgraph sets that subgraph's direction. +- Statement lines are exempt from edge and bracket rules: `click`, `style`, `classDef`, `linkStyle`, + `class`, `accTitle`, `accDescr`, `title`. +- `%%` starts a comment outside a quoted span. `%%{init: {...}}%%` is a directive, not a comment. +- Labels may carry HTML (`<br/>`, `<b>`) and Markdown strings in backticks. Angle brackets are never + structural. +- Mermaid has no backslash escape; use the `#quot;` entity for a double quote inside a label. + +## Example + +```mermaid +flowchart LR + A[Client] --> B{Authenticated} + B -->|yes| C[Handler] + B -->|no| D((Reject)) + subgraph backend [Backend services] + direction TB + C --> E[(Database)] + C -.-> F[Queue] + end + click C "https://example.com/handler" "Open the handler" + classDef hot fill:#f96,stroke:#333 + class E hot +``` diff --git a/.claude/skills/mermaid-diagram/references/gantt.md b/.claude/skills/mermaid-diagram/references/gantt.md new file mode 100644 index 000000000..4d9790faf --- /dev/null +++ b/.claude/skills/mermaid-diagram/references/gantt.md @@ -0,0 +1,51 @@ +# Gantt Chart Syntax Reference + +Pinned to Mermaid **11.17.0**. Source: `https://mermaid.js.org/syntax/gantt.html`. +When a construct is absent here, `WebFetch` that page and confirm the form before generating. + +## First-line keyword form + +`gantt`. + +## Body form + +A gantt body is free text to the validator: it carries no edge tokens, and brackets and parentheses +are not structural. A task named `Deploy (phase 1` is accepted by the gate even though it is +untidy, because rejecting it would be a false positive. The date and duration grammar is not +structurally checkable either, so a malformed date passes the gate and fails to render — check +dates by reading them. + +Statement lines: + +- `title <free text>` +- `dateFormat <format>` — the input format of the task dates, for example `YYYY-MM-DD`. +- `axisFormat <format>` — the output format of the axis, for example `%Y-%m-%d`. +- `tickInterval <n><unit>` — for example `1week`, `2day`. +- `excludes <weekends|YYYY-MM-DD|monday..sunday>` +- `todayMarker <off|stroke:...>` +- `section <free text>` opens a section; sections need no closing statement. + +## Task form + +`<task label> :<tags>, <id>, <start or dependency>, <duration or end>` + +- Tags: `done`, `active`, `crit`, `milestone`. +- The start may be a literal date, `after <id>`, or omitted to continue from the previous task. +- The duration is a number with a unit (`3d`, `2w`, `12h`) or an explicit end date. + +## Example + +```mermaid +gantt + title Order service rollout + dateFormat YYYY-MM-DD + axisFormat %m-%d + excludes weekends + section Preparation + Schema migration :done, mig, 2026-01-05, 3d + Contract tests :active, ct, after mig, 4d + section Rollout + Deploy to staging :crit, stg, after ct, 2d + Soak (24 hours minimum) : soak, after stg, 1d + Production cutover :milestone, prod, after soak, 0d +``` diff --git a/.claude/skills/mermaid-diagram/references/other-types.md b/.claude/skills/mermaid-diagram/references/other-types.md new file mode 100644 index 000000000..f37928662 --- /dev/null +++ b/.claude/skills/mermaid-diagram/references/other-types.md @@ -0,0 +1,82 @@ +# Remaining Diagram Types + +Pinned to Mermaid **11.17.0**. Entry point: `https://mermaid.js.org/intro/syntax-reference.html`; +per-type pages at `https://mermaid.js.org/syntax/<type>.html`. When a construct is absent here, +`WebFetch` the type's page and confirm the form before generating. + +Every type on this page is **keyword-checked only** by the structural gate: the validator confirms +the first-line keyword and declines to judge the body, because these grammars are free text, +indentation-structured, CSV-like, or supplied by an external plugin. A body defect in one of these +types therefore passes the gate and fails to render. Read the body. + +## Verified keyword forms + +| Keyword | Type | Body shape | +| --- | --- | --- | +| `journey` | User journey | `section <name>` then `Task: <score>: <Actor>, <Actor>` rows | +| `quadrantChart` | Quadrant chart | `x-axis`, `y-axis`, `quadrant-1`..`quadrant-4`, then `"<label>": [x, y]` points | +| `requirementDiagram` | Requirement diagram | `requirement`/`element` blocks in braces; relationships as `<a> - <verb> -> <b>` | +| `gitGraph` | Git graph | `commit`, `branch`, `checkout`, `merge`, `cherry-pick`. Accepts a direction and trailing colon: `gitGraph LR:`, `gitGraph TB:`, `gitGraph BT:` | +| `mindmap` | Mind map | indentation-structured; node shapes `((circle))`, `))cloud((`, `)bang(`, `{{hexagon}}` | +| `timeline` | Timeline | `title`, optional `section`, then `<period> : <event> : <event>` rows | +| `zenuml` | ZenUML sequence | requires the external `@mermaid-js/mermaid-zenuml` plugin even in browser Mermaid; the gate keyword-accepts and never judges the body | +| `sankey-beta` | Sankey diagram | CSV-like `source,target,value` rows | +| `xychart-beta` | XY chart | `title`, `x-axis`, `y-axis`, `bar [..]`, `line [..]`. Accepts the `horizontal` modifier: `xychart-beta horizontal` | +| `block-beta` | Block diagram | `columns <n>`, block ids, `space`, flowchart-style arrows between blocks | +| `packet` | Packet diagram | `<start>-<end>: "<name>"` rows. `packet-beta` was the earlier keyword and remains accepted | +| `kanban` | Kanban board | indentation-structured columns and cards | +| `architecture-beta` | Architecture diagram | `group`, `service`, `junction`; edges carry port syntax `L`/`R`/`T`/`B`, as in `db:L -- R:server` | +| `radar-beta` | Radar chart | axis list then per-series value rows | +| `treemap-beta` | Treemap | indentation plus `"<label>": <value>` rows | +| `info` | Version info | no body; renders the Mermaid version | + +## Keyword-accept rows: documented types, unverified keyword form + +These types appear in the 11.x documentation sidebar, but their exact first-line keyword form was +not individually verified against the pinned pages. The validator resolves them and records a drift +warning rather than judging the body, so neither spelling costs a false rejection. Confirm the form +by `WebFetch` before relying on one. + +`swimlanes`, `eventmodeling`, `venn`, `ishikawa`, `wardley`, `cynefin`, `treeView`, `railroad` +(`railroad-beta`). + +## Version drift + +The allowlist in `.claude/lib/mermaid/MermaidGrammar.psm1` is a snapshot of 11.17.0, and Mermaid adds +diagram types several times a year. An unknown but keyword-shaped first-line token produces a drift +warning and is allowed. That warning is the signal to confirm the keyword against the documentation +and add it to the table; it is never a reason to abandon the diagram. + +One exception: a token within a single character of a known keyword and at least five characters +long is reported as a misspelling and denied, because a typo is a defect the gate is required to +name. `flowchar TD` is a misspelling of `flowchart`, not a new diagram type. + +## Examples + +```mermaid +gitGraph LR: + commit id: "init" + branch feature + checkout feature + commit id: "work" + checkout main + merge feature +``` + +```mermaid +timeline + title Release history + 2026-01 : 1.0 shipped : docs published + 2026-02 : 1.1 shipped +``` + +```mermaid +journey + title Order placement + section Browse + Search catalogue: 4: Customer + Read reviews: 3: Customer + section Checkout + Enter payment: 2: Customer + Confirm order: 5: Customer, System +``` diff --git a/.claude/skills/mermaid-diagram/references/pie.md b/.claude/skills/mermaid-diagram/references/pie.md new file mode 100644 index 000000000..64ec2bd28 --- /dev/null +++ b/.claude/skills/mermaid-diagram/references/pie.md @@ -0,0 +1,32 @@ +# Pie Chart Syntax Reference + +Pinned to Mermaid **11.17.0**. Source: `https://mermaid.js.org/syntax/pie.html`. +When a construct is absent here, `WebFetch` that page and confirm the form before generating. + +## First-line keyword forms + +- `pie` +- `pie showData` — the modifier appends each slice's numeric value to its legend label. + +## Body form + +- `title <free text>` is optional and appears above the chart. +- Each data row is `"<label>" : <number>`. The label is double-quoted; the value may be an integer + or a decimal. Mermaid computes the percentages, so values need not sum to 100. +- Up to twelve slices render with distinct default colours; beyond that the palette repeats, so + aggregate the tail into one slice rather than emitting twenty. + +The body carries no edge tokens and no structural brackets, so the validator keyword-checks a pie +chart and does not judge the body. An unquoted label or a non-numeric value passes the gate and +fails to render; read the rows. + +## Example + +```mermaid +pie showData + title Test suite composition + "Unit" : 271 + "Hook" : 28 + "Contract" : 15 + "Distribution" : 3 +``` diff --git a/.claude/skills/mermaid-diagram/references/sequence.md b/.claude/skills/mermaid-diagram/references/sequence.md new file mode 100644 index 000000000..14b93e29a --- /dev/null +++ b/.claude/skills/mermaid-diagram/references/sequence.md @@ -0,0 +1,63 @@ +# Sequence Diagram Syntax Reference + +Pinned to Mermaid **11.17.0**. Source: `https://mermaid.js.org/syntax/sequenceDiagram.html`. +When a construct is absent here, `WebFetch` that page and confirm the form before generating. + +## First-line keyword form + +`sequenceDiagram`. No direction modifier. + +## Message tokens + +| Token | Meaning | +| --- | --- | +| `->` | solid line, no arrowhead | +| `-->` | dotted line, no arrowhead | +| `->>` | solid line with arrowhead | +| `-->>` | dotted line with arrowhead | +| `<<->>` | solid bidirectional | +| `<<-->>` | dotted bidirectional | +| `-x` | solid line with a cross (async, lost) | +| `--x` | dotted line with a cross | +| `-)` | solid line with an open arrow (async) | +| `--)` | dotted line with an open arrow | + +Half-arrow variants (`-\`, `-/` families) were added in 11.12.3 and later. + +Everything after the first `:` on a message line is free text: it may contain dashes, angle +brackets, and brackets, and it is never edge-checked. Only the pre-colon segment carries the +message token. + +## Structural conventions + +- `participant <id> as <label>` and `actor <id>` declare lifelines; declaration order fixes the + left-to-right order. +- `activate <id>` / `deactivate <id>`, or a `+`/`-` suffix on the message token, mark activation. +- Block keywords: `loop`, `alt`, `else`, `opt`, `par`, `and`, `critical`, `break`, `rect`, `box`. + Each block is closed by `end`. +- `Note left of <id>`, `Note right of <id>`, `Note over <id>,<id>` place notes. +- `autonumber` numbers messages. `create participant <id>` and `destroy <id>` manage lifeline + lifetime. +- Brackets are NOT structural in a sequence diagram, because message text routinely contains them. + +## Example + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant A as API + participant D as Database + C->>A: POST /orders [payload 2 - 3 items] + activate A + A->>D: INSERT order + D-->>A: order id + A-->>C: 201 Created + deactivate A + alt payment declined + A-->>C: 402 Payment Required + else accepted + A-)C: webhook: order.confirmed + end + Note over C,A: Retry policy is 3 attempts +``` diff --git a/.claude/skills/mermaid-diagram/references/state.md b/.claude/skills/mermaid-diagram/references/state.md new file mode 100644 index 000000000..661b08d38 --- /dev/null +++ b/.claude/skills/mermaid-diagram/references/state.md @@ -0,0 +1,49 @@ +# State Diagram Syntax Reference + +Pinned to Mermaid **11.17.0**. Source: `https://mermaid.js.org/syntax/stateDiagram.html`. +When a construct is absent here, `WebFetch` that page and confirm the form before generating. + +## First-line keyword forms + +`stateDiagram-v2` (preferred) or `stateDiagram` (legacy). Both accept a following `direction` +statement rather than a direction suffix on the keyword line. + +## Transition token + +`-->` is the only transition token. A single-dash `->` is a defect, and a sequence or class token in +a state diagram is a defect. Length variants (`--->`) are accepted. + +The transition label follows the first `:` and is free text: `Idle --> Running: start button`. + +## Structural conventions + +- `[*]` is the start pseudo-state when it is the transition source and the end pseudo-state when it + is the target. +- `state <Name> { ... }` declares a composite state; braces are structural, so every opener needs a + closer. `direction` inside a composite sets that composite's direction. +- `state "free text description" as <id>` names a state whose label is not identifier-shaped. +- Fork and join use `<<fork>>` and `<<join>>` annotations; a choice point uses `<<choice>>`. +- `note left of <id>` / `note right of <id>` place notes; a `note` block is closed by `end note`. +- `--` inside a composite state separates concurrent regions. + +## Example + +```mermaid +stateDiagram-v2 + direction LR + [*] --> Idle + Idle --> Validating: submit + state Validating { + direction TB + [*] --> Schema + Schema --> Business: schema ok + Business --> [*] + } + Validating --> Accepted: all checks pass + Validating --> Rejected: any check fails + Accepted --> [*] + Rejected --> Idle: correct and resubmit + note right of Rejected + The reason names the failing check. + end note +``` diff --git a/.claude/skills/parallel-add/SKILL.md b/.claude/skills/parallel-add/SKILL.md index 4c39049dd..d7fc13c70 100644 --- a/.claude/skills/parallel-add/SKILL.md +++ b/.claude/skills/parallel-add/SKILL.md @@ -78,16 +78,21 @@ re-derivation is mandatory and is not an optimization to skip when the checkpoin current-cohort member, so no cohort assignment needs to change. - `DEFER_AND_RECOLOR` — the candidate shares an edge with at least one member of the current cohort, pinned or not-yet-launched. Defer it to a future cohort and recolor by calling - `recolor_unstarted(unstarted_items, conflict_edges, pinned, current_generation, current_cohort=current_cohort)`. + `recolor_unstarted(unstarted_items, conflict_edges, pinned, current_generation, current_cohort=current_cohort, highest_pinned_cohort=highest_pinned_cohort)`. + `highest_pinned_cohort` is derived from re-verified durable state: the highest + current-generation cohort index occupied by any in-flight item. The recolor is a recompute: `recolor_generation` increments by exactly one, and it places every - unstarted item at an index at or above `current_cohort`, strictly above it when a pinned - conflict exists. + unstarted item at an index at or above `current_cohort`, strictly above + `highest_pinned_cohort` when a pinned conflict exists. Derive `current_cohort_members` from the re-verified durable state, not from the cached checkpoint: it is the full membership of the current-generation cohort at `current_cohort`, INCLUDING its not-yet-launched `scheduled` members. Derive `current_cohort` from that same - re-verified state; it is F3's top-level `current_cohort` field and is the index the pinned items - occupy. Both matter because `max_concurrency` caps simultaneously in-flight items independently + re-verified state; it is F3's top-level `current_cohort` field, the lowest current-generation + cohort index still holding a non-terminal item. Under the per-edge barrier an in-flight item is + not confined to that index, so derive `highest_pinned_cohort` from the same re-verified state as + well: the highest current-generation cohort index occupied by any in-flight item. Both matter + because `max_concurrency` caps simultaneously in-flight items independently of cohort size and refills each freed slot from the same current cohort — see `## Cohort Barrier and Max-Concurrency Slot Filling` in `.claude/skills/parallel-orchestrate/SKILL.md` — so the current cohort durably holds `scheduled` diff --git a/.claude/skills/parallel-orchestrate/SKILL.md b/.claude/skills/parallel-orchestrate/SKILL.md index f2fcdf3c2..e50697358 100644 --- a/.claude/skills/parallel-orchestrate/SKILL.md +++ b/.claude/skills/parallel-orchestrate/SKILL.md @@ -65,7 +65,7 @@ Consumption rules: so it does not drift when an item's folder moves from `docs/features/active/` to `docs/features/completed/`. - Read `mode` (`closed` or `open`, defaulting to `closed`), `max_concurrency` (an integer from 1 - through 8, defaulting to 4), and each item's identity and state: `feature_folder`, `kind`, + through 32, defaulting to 4), and each item's identity and state: `feature_folder`, `kind`, `state`, and `blast_radius`. - The manifest is read-only to `parallel-orchestrator`. It is static input authored by `parallel-planner`: never write it, rewrite it, or back-fill a field into it. @@ -111,23 +111,58 @@ in order to combine two cohorts or widen a launch batch. ## Cohort Barrier and Max-Concurrency Slot Filling -Two independent controls govern every launch. The cohort barrier governs when a cohort may start. -`max_concurrency` governs how many items of a started cohort run at once. Neither substitutes for +Two independent controls govern every launch. The cohort barrier governs when an individual item +may start. `max_concurrency` governs how many eligible items run at once. Neither substitutes for the other. -**Cohort barrier.** Cohort `N+1` branches from `main` only after every cohort-`N` item is `merged` -or `worktree_removed`. Increment `current_cohort` only on durable confirmation from -`git worktree list --porcelain`, `git branch`, and -`gh pr view --json state,mergedAt,headRefOid` — never from an in-memory completion notification. A -blocked item (`blocked_ci_loop_limit` or `blocked_drift`) is neither `merged` nor -`worktree_removed`, so a blocked item holds the barrier and cohort `N+1` does not start. +**Cohort barrier (per-edge).** An item may start only when every conflicting neighbour +(`conflict_edges[]`) that sits in a strictly prior current-generation cohort has `merge_status` of +`merged` or `worktree_removed`. `ci_green` does not satisfy the barrier: the pull request is not +merged, so its work is not on `main`. Same-cohort and later-cohort neighbours do not hold an item +back, and items with no conflicting prior-cohort neighbour may start regardless of other cohorts' +progress. The barrier is a predicate over one item's own conflict edges, not a global gate over +whole cohorts. + +Evaluate the predicate only against durable state read from `git worktree list --porcelain`, +`git branch`, and `gh pr view --json state,mergedAt,headRefOid` — never from an in-memory +completion notification. A blocked item (`blocked_ci_loop_limit` or `blocked_drift`) is neither +`merged` nor `worktree_removed`, so it holds every conflicting later-cohort neighbour and, +transitively, the tail of its own conflict component; items outside that component are unaffected. + +**`current_cohort` is a progress indicator, not a gate.** `current_cohort` is the LOWEST +current-generation cohort index that still contains a non-terminal, non-withdrawn item. It is +recomputed and written only on durable confirmation from `git worktree list --porcelain`, +`git branch`, and `gh pr view --json state,mergedAt,headRefOid` — never from an in-memory +completion notification. It gates nothing: no item's eligibility is decided by comparing it to +`current_cohort`, because eligibility is the per-edge predicate above. It is reported in the status +document, is the base index the mutation engine recolors from, and is bounded by rule invariant 14, +whose text is unchanged. Because the barrier is per-edge, in-flight items are not confined to +`current_cohort`; the highest current-generation index any pinned item occupies is a separate value +(`highest_pinned_cohort`, see `## Membership Mutation Protocol (F6)`). + +**Safety argument.** An item that starts under the per-edge rule while a non-conflicting +prior-cohort item is still open branches from a `main` that lacks only non-conflicting merged work. +That is byte-for-byte the situation the same-cohort merge-order text above already accepts as safe: +"Items within a cohort are non-conflicting by construction — a cohort is an independent set in the +conflict graph — so they may branch from the same `main` tip and may merge in any order." The +per-edge barrier extends that accepted situation across cohort boundaries without weakening it, +because the only work the starting item can be missing is work it does not conflict with. That +same-cohort text is unchanged by this rule. + +**Availability argument.** Under a global barrier a single `blocked_ci_loop_limit` or +`blocked_drift` item halts every lane, because no item of the next cohort may start until every +item of the current one is terminal. Under the per-edge rule the blocked item holds only its own +conflict component's tail: its conflicting later-cohort neighbours, and transitively theirs. +Unrelated lanes keep advancing. That is the difference between one stuck item stalling a 13-lane +run and one stuck item stalling one lane. **`max_concurrency` slot filling.** `max_concurrency` caps the number of simultaneously in-flight items independently of cohort size: a cohort of twelve items executes at most `max_concurrency` items at a time. Fill slots in ascending item-key order, keyed on `issue_num`, and refill each freed slot with the next unstarted item of the current cohort in that same ascending item-key -order. A cohort larger than `max_concurrency` therefore launches in several batches from the same -recorded `main` tip. The batching is a pure function, reached on the destination-runtime path as +order. A cohort larger than `max_concurrency` therefore launches in several batches, each from the +`main` tip recorded for that batch. The batching is a pure function, reached on the +destination-runtime path as `bash .claude/lib/bash/compute-concurrency-batches.sh --keys "<k1> <k2> ..." --max-concurrency <n>`. It prints a compact JSON array of arrays, returns the batches in order, and sorts the keys itself, so determinism does not depend on caller ordering. @@ -152,11 +187,33 @@ fire per call with no cross-call state visibility. Neither layer is shipped by this feature; both are named here so the obligation is legible to an operator and to the F7 planner. Until F7 lands, the barrier is enforced by this procedure alone. +**The two layers fail closed differently, and the difference is deliberate.** Do not read either +layer's silence as permission. + +- Layer 1 is PROSPECTIVE and evaluated per launch, so it denies fail-closed on every condition that + leaves the target's own eligibility unknowable: a missing or unparseable checkpoint, an unresolved + feature-folder token, a missing `items[]` record for the target, a target with no + current-generation cohort assignment, a missing neighbour record, and a missing neighbour + `merge_status`. Its one permissive case is neighbour-side: a NEIGHBOUR that carries no + current-generation cohort assignment is skipped rather than denied, because such a neighbour sits + in no prior cohort and therefore constrains nothing. +- Layer 2 is RETROSPECTIVE and evaluated per edge, so it is deliberately silent on an edge it cannot + judge; a malformed edge is invariant 15's to report, not the barrier's. It applies three readings + of the same edge. The STRUCTURAL reading rejects two conflicting items colored into the same + current-generation cohort outright — a violation Layer 1 has no counterpart for, because Layer 1 + only ever asks about strictly prior cohorts. The STATUS reading is the retrospective + contrapositive of the per-edge launch rule. The TEMPORAL reading rejects + `merged_at(earlier) > worktree_created_at(later)`, and degrades to the status reading alone when + either timestamp is absent or is not a string. + ## Per-Item Branch and Worktree Lifecycle -1. Run one `git fetch origin main` immediately before each cohort launch, so every item in that - cohort branches from the same current remote `main` tip rather than from a stale local ref. - Record the fetched tip. +1. Run one `git fetch origin main` immediately before each launch batch, so every item in that + batch branches from the same current remote `main` tip rather than from a stale local ref. + The unit is the launch batch, not the cohort: under the per-edge barrier a cohort's items + become eligible at different times, and a cohort larger than `max_concurrency` launches in + several batches, so a per-cohort fetch would leave later batches on a stale tip. Record the + fetched tip for each batch. 2. Each item's worktree is created by that item's delegation spawn, `Agent(orchestrator, isolation: "worktree", run_in_background: true)`, branched from `origin/main`. Do not create or check out item worktrees by hand. @@ -267,13 +324,14 @@ Procedure, per item: `docs/features/parallel/<slug>/parallel-status.md`. 5. On a merge failure caused by a conflict, follow `## Per-Item Merge-Conflict Handling`. -**F7 dependency.** `.claude/hooks/enforce-epic-merge-gate.ps1` is a project-wide `PreToolUse` -Bash-matcher hook that denies any `gh pr merge --merge` unless an epic-shaped checkpoint satisfies -its allow conditions; its block reason is `EPIC_MERGE_GATE_BLOCKED`. A parallel run has no -epic-shaped checkpoint, so step 3 above is denied until F7 scopes or extends that gate's allow -conditions for the parallel case. This feature modifies no file under `.claude/hooks/` and does not -change `.claude/settings.json`, so the parallel surface is not executable end-to-end before F7 -lands. That limitation is documented, not worked around. +**Merge-gate authorization.** `.claude/hooks/enforce-epic-merge-gate.ps1` is a project-wide +`PreToolUse` Bash-matcher hook that denies any `gh pr merge --merge` unless a checkpoint satisfies +one of its allow conditions; its block reason is `EPIC_MERGE_GATE_BLOCKED`. The gate now authorizes +a parallel per-item merge when the parallel-orchestrator checkpoint has `route_id == "parallel"`, +the target item's `merge_status == "ci_green"`, and, when a PR number is named, it matches the +item's `pr_number`. Step 3 above is therefore permitted for a legitimate parallel merge; a missing, +unreadable, or invalid parallel checkpoint, a target item whose `merge_status` is not `ci_green`, or +a PR number that matches no item still fails closed with `EPIC_MERGE_GATE_BLOCKED`. Branch protection on `main` affects only the pacing of step 3, not its ownership: if `main` requires branches to be up to date, an automated `gh pr update-branch` plus re-green cycle is @@ -310,8 +368,11 @@ this surface. feature. 5. On loop exhaustion, the parent records the terminal `merge_status: blocked_ci_loop_limit` for the item; the child's own checkpoint retains its precise blocked status. A blocked item is neither - `merged` nor `worktree_removed`, so it holds the cohort barrier defined in - `## Cohort Barrier and Max-Concurrency Slot Filling`. + `merged` nor `worktree_removed`, so under the per-edge barrier defined in + `## Cohort Barrier and Max-Concurrency Slot Filling` it holds back exactly its own conflicting + later-cohort neighbours — and, transitively, the tail of its own conflict component. Every item + outside that component, including every item of a later cohort that shares no conflict edge with + it, remains eligible and continues to launch. Boundary with F8: a merge conflict between two same-cohort items is evidence that the declared blast radius under-reported, and this feature records the child's blocked or remediated outcome @@ -469,23 +530,29 @@ integers (`items[].issue_num`) everywhere on this surface. ### Pinning invariant **In-flight items are pinned. Scheduling is recomputed only over the not-yet-started subgraph, and -recoloring is a pure function of `(remaining subgraph, pinned set, pinned cohort index)`.** +recoloring is a pure function of `(remaining subgraph, pinned set, pinned cohort indices)`.** The recolor function takes the induced subgraph of unstarted items (states `proposed`, `admitted`, -`prepared`, `scheduled`), the pinned set (state `in_flight`), the current generation, and — as a -third scheduling input — the current cohort index `current_cohort` that the pinned items occupy. It -returns cohort assignments for unstarted items ONLY: the returned mapping's key set equals the -unstarted set exactly and contains no pinned key. A pinned item is therefore absent from the result -rather than reassigned, and that absence IS the guarantee that a mutation never moves work already -running. +`prepared`, `scheduled`), the pinned set (state `in_flight`), the current generation, and — as two +further scheduling inputs — `current_cohort` and `highest_pinned_cohort`, the highest +current-generation cohort index occupied by any pinned item. Both are derived from re-verified +durable state. Two inputs are required because the per-edge barrier does not confine in-flight items +to one index: an item starts as soon as its own conflicting prior-cohort neighbours are terminal, so +the pinned frontier can span several cohorts. It returns cohort assignments for unstarted items +ONLY: the returned mapping's key set equals the unstarted set exactly and contains no pinned key. A +pinned item is therefore absent from the result rather than reassigned, and that absence IS the +guarantee that a mutation never moves work already running. **Pinned-barrier offset.** The returned indices are ABSOLUTE checkpoint cohort indices at or above -`current_cohort`, and strictly above `current_cohort` whenever any conflict edge joins an unstarted -item to a pinned item. When no such edge exists the lowest returned index equals `current_cohort` -exactly, so unstarted items may share the running cohort and `max_concurrency` slot filling is -preserved. The offset is a single uniform shift applied to every color class, so F2's distinct color -classes remain distinct cohort indices and independence within the unstarted set is preserved -exactly. +`current_cohort`, and strictly above `highest_pinned_cohort` whenever any conflict edge joins an +unstarted item to a pinned item. When no such edge exists the lowest returned index equals +`current_cohort` exactly, so unstarted items may share the running cohort and `max_concurrency` slot +filling is preserved. Shifting above the highest pinned index — rather than above `current_cohort` +alone — is what keeps a deferred candidate off the index of any pinned item it conflicts with when +the pinned frontier spans more than one cohort. The offset is a single uniform shift applied to +every color class, so F2's distinct color classes remain distinct cohort indices and independence +within the unstarted set is preserved exactly. When every pinned item sits at `current_cohort` the +two inputs coincide and the offset is identical to the earlier single-frontier rule. Write the returned indices VERBATIM into `cohorts[].index`; never re-base them to zero. `cohorts[]` carries exactly ONE current-generation entry per index, so returned keys landing on index @@ -506,8 +573,14 @@ freed slot from the same current cohort — see `## Cohort Barrier and Max-Concurrency Slot Filling` — so the current cohort durably holds not-yet-launched `scheduled` members that a candidate can contend with. Recoloring previously dropped the candidate-to-pinned edges together with the pinned vertices, which discarded the pinned -CONSTRAINT as well as the pinned VERTICES and returned a deferred candidate to cohort 0, the current -cohort, whenever the cohort barrier held `current_cohort` at 0. +CONSTRAINT as well as the pinned VERTICES and returned a deferred candidate to the current cohort, +undoing the deferral. + +**Third design correction (per-edge barrier).** The offset previously shifted to +`current_cohort + 1`, which was sound only while the documented barrier was global and every pinned +item therefore sat at `current_cohort`. Under the per-edge barrier the pinned frontier can span +several indices, so the offset now shifts above `highest_pinned_cohort`. Where the frontier is a +single index the two expressions agree, so no reachable earlier recoloring changed. ### Recompute boundary @@ -613,9 +686,11 @@ engine's `build_requeue_entry` constructor and the recolor through `recolor_unst `new_state: blocked`, `disposition: null`, and `recolor_generation` equal to `g` + 1 — the requeue is a recompute. - The recolor runs over the unstarted subgraph only, so no other in-flight item moves. Its call - shape is the five-argument form - `recolor_unstarted(unstarted_items, conflict_edges, pinned, current_generation, current_cohort=current_cohort)`, - where `current_cohort` is required and keyword-only. + shape is the six-argument form + `recolor_unstarted(unstarted_items, conflict_edges, pinned, current_generation, current_cohort=current_cohort, highest_pinned_cohort=highest_pinned_cohort)`, + where `current_cohort` and `highest_pinned_cohort` are both required and keyword-only. + `highest_pinned_cohort` is derived from re-verified durable state: the highest + current-generation cohort index occupied by any in-flight item. The drift event itself is recorded in `drift_events[]`, which this protocol does not write. See `## Radius Drift Detection (F8)`. diff --git a/.claude/skills/parallel-plan/SKILL.md b/.claude/skills/parallel-plan/SKILL.md index d0d0fdb0f..de91e3e0a 100644 --- a/.claude/skills/parallel-plan/SKILL.md +++ b/.claude/skills/parallel-plan/SKILL.md @@ -62,9 +62,42 @@ Intake proceeds directly to preparation fan-out. ## Preparation Fan-Out One preparation-mode `Agent(orchestrator)` run per item. Preparation produces documents and plans -rather than code, and items carry no ordering constraint, so launch ALL item preparations -concurrently: one message, N `Agent` calls, each `isolation: "worktree"` and -`run_in_background: true`. Create each preparation worktree's branch from `origin/main`. +rather than code, and items carry no ordering constraint, so preparations may run concurrently — +but they are BOUNDED, not unbounded. + +**Launch preparations in waves of at most `max_concurrency`.** Compute the waves with the same +deterministic chunker the execution phase uses: + +``` +bash .claude/lib/bash/compute-concurrency-batches.sh --keys "<all item keys>" --max-concurrency <n> +``` + +(already granted to this agent at `.claude/agents/parallel-planner.md:18`). It prints a compact +JSON array of arrays, returns the batches in order, and sorts the keys itself, so wave membership +does not depend on caller ordering. Launch wave *k* as one message carrying that wave's `Agent` +calls, each `isolation: "worktree"` and `run_in_background: true`. +Create each preparation worktree's branch from `origin/main`. +Launch wave *k+1* only after every child of wave *k* has TERMINATED — not merely reported +progress. All of this happens inside a single `/parallel-plan` invocation with no operator action +between waves. + +The bound is `max_concurrency` itself, and no new knob is introduced. A preparation child and an +execution child are the same workload class — one background orchestrator per item — so the +operator's declared appetite for concurrent children applies to both phases. At the motivating +scale, `max_concurrency: 13` over 69 items runs `ceil(69 / 13) = 6` waves. + +**A `max_preparation_concurrency` manifest key was considered and is explicitly NOT adopted now.** +Such a key would carry the same `1..32` bounds and the same boolean rejection as M4 and would +default to `max_concurrency`. It is deferred because no evidence yet shows the two phases need +different caps, and adding a second knob would force every operator to reason about two numbers +where one suffices. Revisit it only if a real run shows preparation and execution have materially +different concurrency profiles. + +**`/parallel-add` is NOT the intake path.** It performs incremental admission into an +already-running open-mode queue: exactly one item per invocation, with a single sequential +preparation child. Preparing 69 items through it would take 57 or more separate operator +invocations after the initial plan. Use `/parallel-plan` for intake and `/parallel-add` only to +admit an item into a run that is already in flight. Each delegation prompt includes this literal kickoff line, followed by the model-budget marker line: @@ -188,14 +221,34 @@ separator-free repository-root shared surfaces from plan and spec text, admittin as an exact ordinal member of the configured `shared_surfaces` list in `config/blast-radius.json`; and the contention path comparison now honours listed-directory prefixes on both sides, aligning with `is_path_subsumed`. Both corrections move results in the fail-closed direction — they report -more contention, not less. Do not work around either correction, and do not narrow a radius in -order to suppress a conflict edge they produce. +more contention, not less. Do not work around either correction. + +**The exclusions are configured, not improvised (issue #489).** `config/blast-radius.json` carries +an optional `mandate_reads` list naming the paths every agent is instructed to read before doing +any work: the policy rules, the tier map, and the process artifacts. A citation of one of those +paths is evidence that the author obeyed the reading order, not evidence that the change will write +the file, so `derive_blast_radius` drops it from the harvest and `validate_blast_radius` drops it +from its plan-side extraction, which keeps V1 and V2 self-consistent. The extractor likewise rejects +three token shapes that were never write claims: a wildcard-free token naming a directory rather +than a file, a `docs/features/` glob whose wildcard spans every feature folder, and a contract token +carrying no ASCII letter. These exclusions are part of the landed contract, so the prohibition now +reads: do not narrow a radius beyond the configured exclusions in order to suppress a conflict edge. + +**Appending an excluded path is the planner's obligation, not an exception.** An exclusion describes +the default reading relationship, not a permanent ban. When an item's plan will genuinely WRITE a +path that the exclusions remove — amending a rule file under `.claude/rules/`, editing +`quality-tiers.yml`, or changing an instruction document under `.github/instructions/` — the planner +MUST append that exact path to the item's declared radius explicitly after normalization. Omitting +it under-reports contention and lets two items that both rewrite the same policy file run +concurrently. ### Planner procedure 1. After an item's plan is approved and preflight-clear, read the approved plan text and the - feature `spec.md` text, derive the radius with `source: "declared"`, and record it on the item. - The `declared` radius is the authoritative input to scheduling. + feature `spec.md` text, derive the radius with `source: "declared"`, then call + `normalize_declared_radius(radius, config)` to re-apply the current extraction rules and the + configured exclusions, append any excluded path the plan's diff will genuinely write, and record + the result on the item. The `declared` radius is the authoritative input to scheduling. 2. Validate the radius and record the findings under the item's `radius_validation` entry. 3. **V1 (coverage) or V2 (shared-surface enumeration) Blocking failure.** The item does NOT transition to `prepared`. Record the findings in the checkpoint and issue a follow-up @@ -250,10 +303,27 @@ The library returns the partition; the planner supplies the record fields. item is `prepared` and radius-validated. Derive the conflict edge set by applying `Test-BlastRadiusConflict` to every unordered pair of `declared` radii, then pass the pairs as `--edges "<a>:<b> ..."` and the item keys as `--keys "<k1> <k2> ..."`. -2. Record `cohorts[]` at `generation: 0`, each cohort's `item_keys[]` sorted ascending. -3. Record `conflict_edges[]` as `{a, b, reason}` entries for auditability. -4. Record `recolor_generation: 0` and `current_cohort: 0`. -5. Record `max_concurrency` — default 4, bounded 1 through 8 by the F3 schema — without enforcing +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/<slug>/parallel.md --edges "<a>:<b> ..."` + (covered by the planner's existing `Bash(poetry run *)` grant). 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 + naming no manifest item, and — informational only — a manifest item covered by no expected + component. + **The diagnostic is ADVISORY ONLY.** It never blocks the run, never modifies or suppresses a + derived edge, never feeds `compute_cohorts`, and never influences scheduling. It always exits 0, + including when it reports disagreements. A disagreement is a signal to re-examine the blast + radii; it is never a licence to narrow a radius to suppress an edge, which stays prohibited. + When the manifest carries no `expected_conflict_components` key the diagnostic still runs and + reports every item as uncovered, which is the expected output for a run with no assertion. + Recording the diagnostic's result in the planner checkpoint is a tolerated extra field, not a + validated one; no validator changes for it. +3. Record `cohorts[]` at `generation: 0`, each cohort's `item_keys[]` sorted ascending. +4. Record `conflict_edges[]` as `{a, b, reason}` entries for auditability. +5. Record `recolor_generation: 0` and `current_cohort: 0`. +6. Record `max_concurrency` — default 4, bounded 1 through 32 by the F3 schema — without enforcing it. Enforcement is F5's, through `bash .claude/lib/bash/compute-concurrency-batches.sh --keys "<k1> ..." --max-concurrency <n>` (the bash port of `compute_concurrency_batches(cohort_item_keys, max_concurrency)`), which fills @@ -281,16 +351,23 @@ No production module is added for this check; it is a re-invocation of the lande ## Manifest Authoring Write `docs/features/parallel/<slug>/parallel.md` conforming to the F3-owned frontmatter schema -recorded in `.claude/rules/parallel-orchestration.md` (manifest invariants M1-M7): +recorded in `.claude/rules/parallel-orchestration.md` (manifest invariants M1-M8): - `parallel` — the run slug, a non-empty string. - `mode` — `closed` or `open`; defaults to `closed` when absent. -- `max_concurrency` — an integer from 1 through 8; defaults to `4` when absent. +- `max_concurrency` — an integer from 1 through 32; defaults to `4` when absent. - `created_at` — a non-empty ISO-8601 string. - `items[]` — one entry per item, each carrying `issue_num` (a positive integer, unique across items), `feature_folder` (a non-empty string), `kind` (`feature` or `bug`), `state`, and `blast_radius` carrying `paths`, `modules`, `shared_surfaces`, `contracts`, `source: "declared"`, and `computed_at`. +- `expected_conflict_components[]` — OPTIONAL (invariant M8). A block sequence of objects, each + carrying a required non-empty `members` list of positive `issue_num` integers that resolve to + declared items, with no item in two components, plus an optional non-empty-string `name` used as + a diagnostic label only. A flow-style value (`members: [101, 102]`) is outside the bash YAML + subset and must not be authored. The field is an ASSERTION consumed by the advisory lane + diagnostic in `### Seeding procedure`: it never overrides a derived edge, never feeds + `compute_cohorts`, and never influences scheduling. The manifest carries no `depends_on` field at any level and no top-level `integration_branch` field; both are prohibited-key rejections in the schema. Commit it to `parallel/<slug>-plan` in @@ -454,6 +531,11 @@ The final report to the operator must include: - Per item: one `plan-path:` line, the branch name, the preflight status, and the radius-validation result, including any V3 Advisory findings. - The cohort table at `generation 0`, together with the result of the recomputation-parity check. +- The lane-assertion diagnostic's result: the derived conflict-component count, the disagreement + count, and every `ADVISORY` line it emitted. This line-item is REQUIRED and is reported even when + the manifest carries no `expected_conflict_components` assertion and even when the diagnostic + found nothing, so its silence is never ambiguous. Report it as advisory information: it does not + gate the report, does not change the cohort table, and no finding is escalated to Blocking. - Both kickoff artifact paths: `artifacts/orchestration/parallel-kickoff-<slug>.md` and `docs/features/parallel/<slug>/parallel-kickoff.md`. diff --git a/.claude/skills/parallel-remove/SKILL.md b/.claude/skills/parallel-remove/SKILL.md index 68b7b8764..7381429be 100644 --- a/.claude/skills/parallel-remove/SKILL.md +++ b/.claude/skills/parallel-remove/SKILL.md @@ -79,13 +79,17 @@ Do not record a partial removal, and do not record the rejection itself in `muta 3. **Unstarted removal (recompute).** Set the item's state to `withdrawn`, drop its vertex, and recolor by calling `recolor_unstarted(unstarted_items, conflict_edges, pinned, - current_generation, current_cohort=current_cohort)`. Write + current_generation, current_cohort=current_cohort, + highest_pinned_cohort=highest_pinned_cohort)`. Write `RecolorResult.cohort_assignments` into `cohorts[]` and set the top-level `recolor_generation` to `RecolorResult.generation`; the generation increments by exactly one. The result names no pinned key, so no in-flight item moves. - `current_cohort` is F3's top-level field, read from the re-verified durable state, and is the - index the pinned items occupy. The returned indices are ABSOLUTE and are written VERBATIM into + `current_cohort` is F3's top-level field, read from the re-verified durable state: the lowest + current-generation cohort index still holding a non-terminal item. Under the per-edge barrier an + in-flight item is not confined to that index, so `highest_pinned_cohort` — the highest + current-generation cohort index occupied by any in-flight item — is read from the same + re-verified state. The returned indices are ABSOLUTE and are written VERBATIM into `cohorts[].index`, never re-based to zero. Returned keys whose index equals `current_cohort` are MERGED into the single existing current-generation cohort entry at that index alongside its pinned members, never written as a second entry carrying the same `index`, which F3 invariant 13 diff --git a/.claude/skills/powershell-qa-gate/SKILL.md b/.claude/skills/powershell-qa-gate/SKILL.md index b2e2be49c..23140e2d5 100644 --- a/.claude/skills/powershell-qa-gate/SKILL.md +++ b/.claude/skills/powershell-qa-gate/SKILL.md @@ -42,7 +42,7 @@ Compare the final results to the Phase A baseline. All of the following must hol - **Pester delta**: 0 new failing tests. - **Per-file coverage delta**: coverage for every touched file is greater than or equal to the baseline for that file. - **Overall coverage delta** (when the repo enforces it): overall coverage is greater than or equal to the baseline. -- **New modules, classes, or methods**: line coverage >= 85% and branch coverage >= 75% per the uniform tier rule (`.claude/rules/quality-tiers.md`). No tier-specific lower thresholds. No regression on changed lines. +- **New modules, classes, or methods**: line coverage >= 85% per the uniform tier rule (`.claude/rules/quality-tiers.md`). No tier-specific lower thresholds. No regression on changed lines. Pester measures command (instruction) coverage and line coverage only; branch coverage is not measurable for PowerShell, so no branch-coverage gate applies here (see `.claude/rules/powershell.md`). Command coverage is informational and carries no threshold. If any delta check fails, the agent must revert or fix immediately and rerun the full toolchain. Do not proceed to reporting until all deltas are clean. diff --git a/TaskMaster/TaskMaster.csproj b/TaskMaster/TaskMaster.csproj index cfb01dfd3..39e71f0d4 100644 --- a/TaskMaster/TaskMaster.csproj +++ b/TaskMaster/TaskMaster.csproj @@ -37,7 +37,7 @@ <PublishUrl>C:\Users\DanMoisan\OneDrive - The Real Good Food Company\TM\</PublishUrl> <InstallUrl /> <TargetCulture>en</TargetCulture> - <ApplicationVersion>1.0.0.25</ApplicationVersion> + <ApplicationVersion>1.0.0.27</ApplicationVersion> <AutoIncrementApplicationRevision>true</AutoIncrementApplicationRevision> <UpdateEnabled>true</UpdateEnabled> <UpdateInterval>7</UpdateInterval> diff --git a/config/blast-radius.json b/config/blast-radius.json index 42e1a02ce..44332f4a2 100644 --- a/config/blast-radius.json +++ b/config/blast-radius.json @@ -6,11 +6,75 @@ "config/blast-radius.json" ], "shared_surface_globs": [], + "mandate_reads": [ + ".claude/rules/**", + ".claude/skills/atomic-plan-contract/SKILL.md", + ".claude/skills/evidence-and-timestamp-conventions/SKILL.md", + ".github/instructions/**", + "artifacts/**", + "quality-tiers.yml" + ], "modules": { - "claude-runtime": [".claude/**"], - "config": ["config/**"], - "docs": ["docs/**"], - "tests": ["tests/**"] + "QuickFiler": [ + "QuickFiler/**" + ], + "QuickFiler.Test": [ + "QuickFiler.Test/**" + ], + "SVGControl": [ + "SVGControl/**" + ], + "SVGControl.Test": [ + "SVGControl.Test/**" + ], + "Tags": [ + "Tags/**" + ], + "Tags.Test": [ + "Tags.Test/**" + ], + "TaskMaster": [ + "TaskMaster/**" + ], + "TaskMaster.Test": [ + "TaskMaster.Test/**" + ], + "TaskTree": [ + "TaskTree/**" + ], + "TaskTree.Test": [ + "TaskTree.Test/**" + ], + "TaskVisualization": [ + "TaskVisualization/**" + ], + "TaskVisualization.Test": [ + "TaskVisualization.Test/**" + ], + "ToDoModel": [ + "ToDoModel/**" + ], + "ToDoModel.Test": [ + "ToDoModel.Test/**" + ], + "UtilitiesCS": [ + "UtilitiesCS/**" + ], + "UtilitiesCS.Test": [ + "UtilitiesCS.Test/**" + ], + "VBFunctions": [ + "VBFunctions/**" + ], + "VBFunctions.Test": [ + "VBFunctions.Test/**" + ], + "claude-runtime": [ + ".claude/**" + ], + "config": [ + "config/**" + ] }, "over_breadth_fraction": 0.25 }