diff --git a/.agents/skills/codex-model-routing/SKILL.md b/.agents/skills/codex-model-routing/SKILL.md index a7ace1983..edfe950f6 100644 --- a/.agents/skills/codex-model-routing/SKILL.md +++ b/.agents/skills/codex-model-routing/SKILL.md @@ -65,6 +65,16 @@ epic root personas are forced independently of file count. 7. Spawn the generated agent profile. Do not spawn the base alias and claim that a different model was selected. +## Normal routed-delegation launch binding + +Before every normal nested `spawn_agent` call, resolve independently for that delegation. Validate the generated profile name, model, reasoning effort, path, and SHA-256 against the resolver result and the generated profile on disk. Add +the exact validated receipt to `codex_model_routing_receipts[]`, including its non-empty `phase` and delegation identifier, in the selected checkpoint that `SubagentStart` reads. Durably flush the selected checkpoint before launch, then launch only the resolver-returned `deployment_agent`. + +Reject the launch when the receipt is late, a generic alias is supplied, the +checkpoint is ambiguous, profile validation fails, persistence or durable flush +fails, or start attestation returns `routing_valid: false`. Do not accept child output or child mutations after `routing_valid: false`; retain downstream +recorder, authority-store, mutation-gate, and stop-gate enforcement. + The route name `feature-review` resolves to the native `feature-reviewer-` agent family; retain `feature-review` as the receipt's logical agent name. diff --git a/.agents/skills/epic-plan/SKILL.md b/.agents/skills/epic-plan/SKILL.md index 0bd0d667a..639ee7fd5 100644 --- a/.agents/skills/epic-plan/SKILL.md +++ b/.agents/skills/epic-plan/SKILL.md @@ -161,7 +161,7 @@ As each preparation finishes: 3. Backfill `issue_num` and `feature_folder` in `epic.md`. 4. Record `preparation_status: "prepared"`, `research_path`, `plan_path`, `preflight_evidence_path`, and `preflight_status: "PREFLIGHT: ALL CLEAR"` in the planner - checkpoint. `research_path` must be under `artifacts/research/` or inside the feature folder. + checkpoint. `research_path` must be under `docs/features//research/` inside the feature folder. Each child references its immutable receipt and the shared `wave..status.json` path. 5. Remove the worktree only after its preparation branch is merged. diff --git a/.agents/skills/evidence-and-timestamp-conventions/SKILL.md b/.agents/skills/evidence-and-timestamp-conventions/SKILL.md index 9dd9182f8..44497ed7a 100644 --- a/.agents/skills/evidence-and-timestamp-conventions/SKILL.md +++ b/.agents/skills/evidence-and-timestamp-conventions/SKILL.md @@ -31,7 +31,8 @@ The following sub-paths under `artifacts/` are FORBIDDEN for evidence output: Allowed `artifacts/` sub-paths (non-evidence orchestration use only): - `artifacts/orchestration/` -- `artifacts/research/` + +Research output is not an `artifacts/` sub-path. Use `docs/features//research/` for feature-associated research or `docs/research/` for one-off research. No delegation prompt, plan task, or upstream agent instruction may override this scheme. If a caller supplies a non-canonical path, the receiving agent MUST reject it, substitute the canonical path, and record `EVIDENCE_LOCATION_OVERRIDE_REJECTED: replaced with `. diff --git a/.agents/skills/invoke-powershell-engineer/SKILL.md b/.agents/skills/invoke-powershell-engineer/SKILL.md index d14bc800f..6403c69c4 100644 --- a/.agents/skills/invoke-powershell-engineer/SKILL.md +++ b/.agents/skills/invoke-powershell-engineer/SKILL.md @@ -1,6 +1,6 @@ --- name: invoke-powershell-engineer -description: Invoke the powershell-typed-engineer worker to design, implement, and verify PowerShell changes within typed repository boundaries. Applies PoshQC format -> analyze -> test toolchain, the 1-2 production-file direct-mode budget, the 3-production + 3-test per-batch cap, and zero-regression quality gates. +description: Invoke the powershell-typed-engineer worker to design, implement, and verify PowerShell changes within typed repository boundaries. Applies PoshQC formatting, analysis, and testing with a one-to-two production-file direct-mode budget, a three-production plus three-test batch cap, and zero-regression quality gates. --- # Implement PowerShell Skill diff --git a/.agents/skills/orchestrate/SKILL.md b/.agents/skills/orchestrate/SKILL.md index 6a8207a83..7d5953f25 100644 --- a/.agents/skills/orchestrate/SKILL.md +++ b/.agents/skills/orchestrate/SKILL.md @@ -284,7 +284,7 @@ After reading `artifacts/orchestration/orchestrator-state.json`, the main sessio - `atomic-executor` — executes approved plans task-by-task - `feature-reviewer` — produces policy, code, and feature audit artifacts by applying the `feature-review` workflow skill -- `task-researcher` — performs deep research and writes findings to `artifacts/research/` +- `task-researcher` — performs deep research and writes findings to the applicable feature-associated `docs/features//research/` or one-off `docs/research/` root - `prd-feature` — produces issue, specification, and user-story artifacts when required by the selected workflow - `staged-review` — reviews staged changes when a pre-commit review is required - `epic-review` — reviews epic-level artifacts when the work item is an epic @@ -326,7 +326,6 @@ All evidence artifacts produced during orchestration MUST comply with the canoni Permitted `artifacts/`-rooted sub-paths (non-evidence orchestration use only): - `artifacts/orchestration/` — orchestrator state and checkpoints -- `artifacts/research/` — research outputs from task-researcher - `artifacts/pr_context` — PR context artifacts - `artifacts/reviews/` — review staging artifacts - `artifacts/status/` — status update artifacts @@ -334,6 +333,8 @@ Permitted `artifacts/`-rooted sub-paths (non-evidence orchestration use only): - `artifacts/pester/` — Pester coverage outputs - `artifacts/csharp/` — C# coverage outputs +Research outputs from `task-researcher` are written to `docs/features//research/` for feature-associated work or `docs/research/` for one-off work. + All other `artifacts/` sub-paths (e.g., `artifacts/baselines/`, `artifacts/qa/`, `artifacts/coverage/`, `artifacts/evidence/`) are FORBIDDEN for evidence output and will be blocked by the `enforce-evidence-locations.ps1` PreToolUse hook. ## Completion Requirements diff --git a/.agents/skills/powershell-change-budget-router/SKILL.md b/.agents/skills/powershell-change-budget-router/SKILL.md index 2f5c89b74..59eed008b 100644 --- a/.agents/skills/powershell-change-budget-router/SKILL.md +++ b/.agents/skills/powershell-change-budget-router/SKILL.md @@ -1,6 +1,6 @@ --- name: powershell-change-budget-router -description: Budget-first routing contract for PowerShell work: estimate production-file scope, choose small vs large path, enforce direct-mode escalation to orchestrator, and use the VS Code extension command surface for promotion lifecycle steps when available. +description: 'Budget-first routing contract for PowerShell work: estimate production-file scope, choose small vs large path, enforce direct-mode escalation to orchestrator, and use the VS Code extension command surface for promotion lifecycle steps when available.' --- # PowerShell Change Budget Router diff --git a/.agents/skills/powershell-qa-gate/SKILL.md b/.agents/skills/powershell-qa-gate/SKILL.md index b40a42a1f..d37f4635b 100644 --- a/.agents/skills/powershell-qa-gate/SKILL.md +++ b/.agents/skills/powershell-qa-gate/SKILL.md @@ -1,6 +1,6 @@ --- name: powershell-qa-gate -description: Final QA gate for PowerShell changes. Executes the full PoshQC format -> analyze -> test toolchain (with coverage where enforced), compares against a captured baseline, enforces zero-regression deltas, and produces the required reporting block before the agent declares the change complete. +description: Final QA gate for PowerShell changes. Runs PoshQC formatting, analysis, and testing with coverage where enforced; compares a captured baseline, enforces zero-regression deltas, and produces the required reporting block before completion. --- # PowerShell QA Gate diff --git a/.agents/skills/powershell/SKILL.md b/.agents/skills/powershell/SKILL.md index 7c3bf836b..1f47accd2 100644 --- a/.agents/skills/powershell/SKILL.md +++ b/.agents/skills/powershell/SKILL.md @@ -1,9 +1,5 @@ --- name: powershell -paths: - - "**/*.ps1" - - "**/*.psm1" - - "**/*.psd1" description: PowerShell-specific toolchain and coding standards. --- diff --git a/.claude/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 b/.claude/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 new file mode 100644 index 000000000..3a94482b3 --- /dev/null +++ b/.claude/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 @@ -0,0 +1,477 @@ +<# +.SYNOPSIS + Mode dispatch and per-mode readiness predicates for the orchestration + preimplementation gate (Claude surface). +.DESCRIPTION + Normative contract: the issue #554 mode dispatch and readiness predicates. + This file owns the fixed mode table, the canonical checkpoint-path map, the + implementation-agent allow-list, mode resolution, the target-token finder, the + prompt-declared-path cross-check, and the epic and parallel readiness + predicates. It owns nothing else. + + The readiness source is resolved from the recognized mode marker through the + fixed table below, and NEVER from a path parsed out of a prompt: a delegation + that named its own readiness file would choose its own gate. A prompt-declared + path is a cross-check operand only. The posture follows the shipped precedents + enforce-epic-wave-barrier.ps1 and enforce-parallel-cohort-barrier.ps1. + + PURITY. This file is pure string and object logic, with no filesystem, + process, network, or environment access: it opens no file, probes no path, + issues no web request, launches no executable, and imports no module. Every + readiness predicate accepts an ALREADY-PARSED checkpoint object, or $null; the + per-mode read seams live in the main gate hook. It is a new sibling rather than + an addition to the issue #539 helpers file, whose header declares a different + normative contract and which lacks headroom under the 500-line cap; leaving + that file byte-untouched is the proof the #539 exemption is unchanged. +#> +[CmdletBinding()] +param() + +# --- Constant table 1: the fixed mode table -------------------------------------- +# Markers are reused verbatim from shipped contracts and hooks; do not invent them. +# The trailing-period asymmetry is deliberate: preparation markers are matched WITH +# their periods as the shipped gate hook does and an existing test pins, epic and +# parallel WITHOUT, as the two barrier hooks do, which makes the three hooks on the +# same Agent matcher agree. MatchCase carries the same asymmetry. Both forms are +# containment tests over the prompt, so neither is sensitive to edge whitespace. +# Rows evaluate in order, so preparation is first and exempts; all markers on a row +# must be present for that row to match. +$script:OrchestrationDelegationModeTable = @( + [pscustomobject]@{ + Mode = 'preparation' + Markers = @('Preparation mode: true.', 'route_id: preparation.') + MatchCase = $true + } + [pscustomobject]@{ Mode = 'epic'; Markers = @('Epic mode: true'); MatchCase = $false } + [pscustomobject]@{ Mode = 'parallel'; Markers = @('Parallel mode: true'); MatchCase = $false } +) + +# The mode a prompt carrying no recognized marker resolves to. +$script:OrchestrationDelegationDefaultMode = 'single-feature' + +# --- Constant table 2: the canonical checkpoint-path map ------------------------- +# Preparation is exempt and has no readiness source, expressed as an empty string +# so callers test it with one truthiness check. No value here is ever derived from +# prompt text. +$script:OrchestrationDelegationCheckpointPathMap = [ordered]@{ + 'preparation' = '' + 'epic' = 'artifacts/orchestration/epic-orchestrator-state.json' + 'parallel' = 'artifacts/orchestration/parallel-orchestrator-state.json' + 'single-feature' = 'artifacts/orchestration/orchestrator-state.json' +} + +# --- Constant table 3: the implementation-agent allow-list ----------------------- +# Exactly five members: the agent tokens carried over from the replaced seven-token +# regex, dropping only the two free-text tokens. Retaining atomic-executor and the +# four typed-engineer names is a hard invariant; pre-existing cases supply two of +# them and assert deny. +$script:OrchestrationImplementationAgentAllowList = @( + 'python-typed-engineer' + 'powershell-typed-engineer' + 'typescript-engineer' + 'csharp-typed-engineer' + 'atomic-executor' +) + +function Get-OrchestrationModeProperty { + <# + .SYNOPSIS + Reads a named property off an already-parsed object, or $null. The single + field-access seam here; never throws. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)][AllowNull()] $Value, + [Parameter(Mandatory)][string] $Name + ) + + if ($null -eq $Value) { return $null } + $properties = $null + try { + $properties = $Value.PSObject.Properties + } catch { + Write-Debug "Property probe failed for '$Name': $($_.Exception.Message)" + return $null + } + if ($null -eq $properties -or -not ($properties.Name -contains $Name)) { return $null } + return $properties[$Name].Value +} + +function Get-OrchestrationModeString { + <# + .SYNOPSIS + Reads a named property as a trimmed string, or an empty string. The name is + distinct from the gate hooks' Get-StringProperty so it cannot shadow it. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)][AllowNull()] $Value, + [Parameter(Mandatory)][string] $Name + ) + + $raw = Get-OrchestrationModeProperty -Value $Value -Name $Name + if ($null -eq $raw) { return '' } + return ([string]$raw).Trim() +} + +function Get-OrchestrationModeCollection { + <# + .SYNOPSIS + Reads a named property as an array, or an empty array. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)][AllowNull()] $Value, + [Parameter(Mandatory)][string] $Name + ) + + $raw = Get-OrchestrationModeProperty -Value $Value -Name $Name + if ($null -eq $raw) { return @() } + return @($raw) +} + +function Get-OrchestrationModeFolderBasename { + <# + .SYNOPSIS + Normalizes a feature_folder value to its bare basename. A record's value + may be a full path with a lifecycle prefix or a bare basename, so both + sides of every comparison are normalized, following the cohort-barrier hook. + #> + [CmdletBinding()] + [OutputType([string])] + param([AllowNull()][AllowEmptyString()][string] $Path) + + if (-not $Path) { return '' } + $normalized = ($Path -replace '\\', '/').TrimEnd('/') + if (-not $normalized) { return '' } + return ($normalized -split '/')[-1] +} + +function Resolve-OrchestrationDelegationMode { + <# + .SYNOPSIS + Resolves a delegation prompt to one of the four mode names. Reads nothing + but the supplied string; evaluates preparation, then epic, then parallel, + then the default. Null and empty prompts resolve to the default. No + checkpoint path is ever read out of a prompt. + #> + [CmdletBinding()] + [OutputType([string])] + param([AllowNull()][AllowEmptyString()][string] $Prompt) + + if (-not $Prompt) { return $script:OrchestrationDelegationDefaultMode } + + foreach ($row in $script:OrchestrationDelegationModeTable) { + $allPresent = $true + foreach ($marker in $row.Markers) { + $present = if ($row.MatchCase) { + $Prompt.Contains($marker) + } else { + $Prompt -like ('*' + $marker + '*') + } + if (-not $present) { + $allPresent = $false + break + } + } + if ($allPresent) { return $row.Mode } + } + return $script:OrchestrationDelegationDefaultMode +} + +function Get-OrchestrationDelegationCheckpointPath { + <# + .SYNOPSIS + Returns the canonical readiness source for a mode name, from the fixed + table and nowhere else. Preparation returns an empty string, as does an + unrecognized name, so no caller can manufacture a source. + #> + [CmdletBinding()] + [OutputType([string])] + param([AllowNull()][AllowEmptyString()][string] $Mode) + + if (-not $Mode -or -not $script:OrchestrationDelegationCheckpointPathMap.Contains($Mode)) { + return '' + } + return [string]$script:OrchestrationDelegationCheckpointPathMap[$Mode] +} + +function Test-OrchestrationImplementationAgent { + <# + .SYNOPSIS + Tests a subagent_type value against the implementation-agent allow-list. + #> + [CmdletBinding()] + [OutputType([bool])] + param([AllowNull()][AllowEmptyString()][string] $SubagentType) + + if (-not $SubagentType) { return $false } + return ($script:OrchestrationImplementationAgentAllowList -contains $SubagentType) +} + +function Find-OrchestrationDelegationTargetFolder { + <# + .SYNOPSIS + Resolves the target feature-folder basename out of a delegation prompt, + reusing the wave-barrier technique in shape: scan for slash-separated + docs/features/active/ tokens, longest unique match wins, a Markdown match + resolves to its parent, and the basename is returned. $null when no token + resolves, which the caller treats as a deny. + #> + [CmdletBinding()] + [OutputType([string])] + param([AllowNull()][AllowEmptyString()][string] $Prompt) + + if (-not $Prompt) { return $null } + + $pattern = 'docs[\\/]+features[\\/]+active[\\/]+[^\s"''`]+' + $matchList = [regex]::Matches($Prompt, $pattern) + if ($matchList.Count -eq 0) { return $null } + + $unique = [ordered]@{} + foreach ($item in $matchList) { $unique[$item.Value] = $true } + $candidates = @(@($unique.Keys) | Sort-Object -Property Length -Descending) + $best = [string]$candidates[0] + + # Sentence punctuation trails a bare path token in every shipped kickoff + # contract. A trailing period is stripped only when it does not form the + # Markdown extension the next branch depends on. + $best = $best.TrimEnd(',', ';', ':') + while ($best.EndsWith('.') -and -not $best.EndsWith('.md')) { + $best = $best.Substring(0, $best.Length - 1) + } + if ($best -match '\.md$') { $best = $best -replace '[\\/][^\\/]+\.md$', '' } + + $basename = Get-OrchestrationModeFolderBasename -Path $best + if (-not $basename) { return $null } + return $basename +} + +function Find-OrchestrationDelegationIssueNumber { + <# + .SYNOPSIS + Resolves an issue number out of a delegation prompt, as a string. The + alternative target resolution of decision D3, issue_num being the primary + key on both checkpoints. The keyed form is preferred over the bare hash + form; $null when neither resolves. Accepted widening: a bare hash form such + as a pull-request reference can supply a number that is not the target's, + which widens the SEARCH only - an unmatched number yields no record and + denies, so deny-by-default is preserved. + #> + [CmdletBinding()] + [OutputType([string])] + param([AllowNull()][AllowEmptyString()][string] $Prompt) + + if (-not $Prompt) { return $null } + + $keyed = [regex]::Match($Prompt, 'issue[_-]?num(?:ber)?\s*[:=]\s*#?(\d+)', 'IgnoreCase') + if ($keyed.Success) { return $keyed.Groups[1].Value } + $hashForm = [regex]::Match($Prompt, '(?:^|\s)#(\d+)\b') + if ($hashForm.Success) { return $hashForm.Groups[1].Value } + return $null +} + +function Test-OrchestrationDelegationDeclaredCheckpointPath { + <# + .SYNOPSIS + Cross-checks a prompt-declared checkpoint path against the mode's canonical + path. True only when the prompt declares none for the mode, or declares one + equal to the canonical value. The declared value is a cross-check operand + ONLY and never selects a source; a disagreement is a deny. A mode with no + declared-path key returns true. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowNull()][AllowEmptyString()][string] $Prompt, + [AllowNull()][AllowEmptyString()][string] $Mode + ) + + if ($Mode -ne 'epic' -and $Mode -ne 'parallel') { return $true } + if (-not $Prompt) { return $true } + + $canonical = Get-OrchestrationDelegationCheckpointPath -Mode $Mode + $key = $Mode + '_checkpoint_path' + $declaredMatch = [regex]::Match( + $Prompt, [regex]::Escape($key) + '\s*[:=]\s*([^\s"''`]+)', 'IgnoreCase') + if (-not $declaredMatch.Success) { return $true } + + $declared = $declaredMatch.Groups[1].Value.TrimEnd(',', ';', ':') + while ($declared.EndsWith('.') -and -not $declared.EndsWith('.json')) { + $declared = $declared.Substring(0, $declared.Length - 1) + } + $declared = $declared -replace '\\', '/' + return ($declared -eq $canonical) +} + +function Test-OrchestrationModeTerminalMergeStatus { + <# + .SYNOPSIS + Tests whether a target record's merge_status is terminal-merged (decision + D8). The two terminal members are merged and worktree_removed, the same two + the barrier hooks treat as terminal-safe. Every other member, including the + failure members, is pre-merge here: re-delegation after a blocked or + conflicted state is legitimate remediation and must not be gated off. An + ABSENT merge_status is treated as not_started, per parallel invariant 7. + This predicate CONSUMES the existing member sets and extends neither. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)][AllowNull()] $Record) + + if ($null -eq $Record) { return $false } + $status = Get-OrchestrationModeString -Value $Record -Name 'merge_status' + if (-not $status) { return $false } + return (@('merged', 'worktree_removed') -contains $status) +} + +function Find-OrchestrationModeRecord { + <# + .SYNOPSIS + Finds the target record in a checkpoint's feature or item collection, + matching the normalized feature_folder basename first and issue_num second. + $null when neither resolves, which is a failed conjunct. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)][AllowNull()] $Records, + [AllowNull()][AllowEmptyString()][string] $TargetFolder, + [AllowNull()][AllowEmptyString()][string] $IssueNumber + ) + + if ($null -eq $Records) { return $null } + foreach ($record in @($Records)) { + if ($null -eq $record) { continue } + if ($TargetFolder) { + $folder = Get-OrchestrationModeString -Value $record -Name 'feature_folder' + $basename = Get-OrchestrationModeFolderBasename -Path $folder + if ($basename -and $basename -eq $TargetFolder) { return $record } + } + if ($IssueNumber) { + $issue = Get-OrchestrationModeString -Value $record -Name 'issue_num' + if ($issue -and $issue -eq $IssueNumber) { return $record } + } + } + return $null +} + +function Get-EpicOrchestrationReadinessFailure { + <# + .SYNOPSIS + Names the first failed epic readiness conjunct, or returns an empty string. + Accepts an already-parsed checkpoint object or $null and enforces, in + order: route_id exactly epic; non-empty epic_feature_folder; non-empty + epic_manifest_path under docs/features/epics/; non-empty + integration_branch; present and non-empty features; the resolved target + present as a record in features; and that record's merge_status neither + merged nor worktree_removed. The epic_manifest_path conjunct deliberately + tightens relative to validate_epic_orchestrator_state.py, whose + required-key set omits it, but is not stricter than the producing skill's + contract, which mandates it; a false deny names the failed conjunct. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)][AllowNull()] $Checkpoint, + [AllowNull()][AllowEmptyString()][string] $TargetFolder, + [AllowNull()][AllowEmptyString()][string] $IssueNumber + ) + + if ($null -eq $Checkpoint) { return 'checkpoint-absent' } + if ((Get-OrchestrationModeString -Value $Checkpoint -Name 'route_id') -ne 'epic') { + return 'route_id' + } + if (-not (Get-OrchestrationModeString -Value $Checkpoint -Name 'epic_feature_folder')) { + return 'epic_feature_folder' + } + $manifest = (Get-OrchestrationModeString -Value $Checkpoint -Name 'epic_manifest_path') -replace '\\', '/' + if (-not $manifest -or $manifest -notmatch '(^|/)docs/features/epics/') { + return 'epic_manifest_path' + } + if (-not (Get-OrchestrationModeString -Value $Checkpoint -Name 'integration_branch')) { + return 'integration_branch' + } + $features = Get-OrchestrationModeCollection -Value $Checkpoint -Name 'features' + if ($features.Count -eq 0) { return 'features' } + $record = Find-OrchestrationModeRecord -Records $features -TargetFolder $TargetFolder -IssueNumber $IssueNumber + if ($null -eq $record) { return 'target-record' } + if (Test-OrchestrationModeTerminalMergeStatus -Record $record) { return 'merge_status' } + return '' +} + +function Test-EpicOrchestrationReady { + <# + .SYNOPSIS + Boolean wrapper over Get-EpicOrchestrationReadinessFailure. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)][AllowNull()] $Checkpoint, + [AllowNull()][AllowEmptyString()][string] $TargetFolder, + [AllowNull()][AllowEmptyString()][string] $IssueNumber + ) + + $failure = Get-EpicOrchestrationReadinessFailure -Checkpoint $Checkpoint ` + -TargetFolder $TargetFolder -IssueNumber $IssueNumber + return (-not $failure) +} + +function Get-ParallelOrchestrationReadinessFailure { + <# + .SYNOPSIS + Names the first failed parallel readiness conjunct, or an empty string. + Accepts an already-parsed checkpoint object or $null and enforces, in + order: route_id exactly parallel; non-empty parallel_slug; non-empty + parallel_manifest_path; present and non-empty items; the resolved target + present as a record in items; and that record's merge_status neither merged + nor worktree_removed. It consumes the parallel item-state and merge-status + member sets and adds no member to either. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)][AllowNull()] $Checkpoint, + [AllowNull()][AllowEmptyString()][string] $TargetFolder, + [AllowNull()][AllowEmptyString()][string] $IssueNumber + ) + + if ($null -eq $Checkpoint) { return 'checkpoint-absent' } + if ((Get-OrchestrationModeString -Value $Checkpoint -Name 'route_id') -ne 'parallel') { + return 'route_id' + } + if (-not (Get-OrchestrationModeString -Value $Checkpoint -Name 'parallel_slug')) { + return 'parallel_slug' + } + if (-not (Get-OrchestrationModeString -Value $Checkpoint -Name 'parallel_manifest_path')) { + return 'parallel_manifest_path' + } + $items = Get-OrchestrationModeCollection -Value $Checkpoint -Name 'items' + if ($items.Count -eq 0) { return 'items' } + $record = Find-OrchestrationModeRecord -Records $items -TargetFolder $TargetFolder -IssueNumber $IssueNumber + if ($null -eq $record) { return 'target-record' } + if (Test-OrchestrationModeTerminalMergeStatus -Record $record) { return 'merge_status' } + return '' +} + +function Test-ParallelOrchestrationReady { + <# + .SYNOPSIS + Boolean wrapper over Get-ParallelOrchestrationReadinessFailure. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)][AllowNull()] $Checkpoint, + [AllowNull()][AllowEmptyString()][string] $TargetFolder, + [AllowNull()][AllowEmptyString()][string] $IssueNumber + ) + + $failure = Get-ParallelOrchestrationReadinessFailure -Checkpoint $Checkpoint ` + -TargetFolder $TargetFolder -IssueNumber $IssueNumber + return (-not $failure) +} diff --git a/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 b/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 index 1ac301f71..69ae42ecc 100644 --- a/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +++ b/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 @@ -13,6 +13,12 @@ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') - # the enforce-pr-author-skill.ps1 headroom-split precedent. . (Join-Path $PSScriptRoot 'enforce-orchestration-preimplementation-gate-helpers.ps1') +# Pure mode dispatch and per-mode readiness predicates for issue #554. A new sibling +# rather than an addition to the helpers file above, whose header declares a different +# normative contract and which lacks headroom; leaving that file byte-untouched is the +# proof the issue #539 exemption is behaviourally unchanged. +. (Join-Path $PSScriptRoot 'enforce-orchestration-preimplementation-gate-modes.ps1') + # The readiness checkpoint this gate reads and names in its block message. $script:CheckpointPath = 'artifacts/orchestration/orchestrator-state.json' @@ -179,6 +185,14 @@ function Test-PreparationModeDelegation { return $true } +# Classifies an Agent delegation as implementation by STRUCTURE (issue #554). Both +# reads are field-scoped through Get-ClaudeHookToolInputString, and the whole-payload +# serialization scan this function used to perform is removed: it let any field, and +# two ordinary English words, decide the outcome, so marker text planted outside +# 'prompt' changed the classification and rewording a prompt changed the decision. +# Neither can happen now. An allow-listed subagent_type is implementation whatever the +# prompt says; any other non-orchestrator subagent_type is not; an orchestrator whose +# resolved mode is preparation is not, and every other orchestrator is. function Test-ImplementationDelegation { [CmdletBinding()] [OutputType([bool])] @@ -188,19 +202,16 @@ function Test-ImplementationDelegation { return $false } - try { - if (Test-PreparationModeDelegation -ToolInput $ToolInput) { - return $false - } - } catch { - # An envelope the field reader cannot probe falls through to the unchanged - # whole-payload regex below. An extraction failure must never become an - # exemption, so the gate stays closed on the stricter classifier. - Write-Debug "Preparation-mode probe failed: $($_.Exception.Message)" + $subagentType = Get-ClaudeHookToolInputString -ToolInput $ToolInput -Name 'subagent_type' + if (Test-OrchestrationImplementationAgent -SubagentType $subagentType) { + return $true + } + if ($subagentType -ne 'orchestrator') { + return $false } - $payloadText = ($ToolInput | ConvertTo-Json -Depth 20 -Compress) - return $payloadText -match '(python-typed-engineer|powershell-typed-engineer|typescript-engineer|csharp-typed-engineer|atomic-executor|implementation|execute)' + $prompt = Get-ClaudeHookToolInputString -ToolInput $ToolInput -Name 'prompt' + return ((Resolve-OrchestrationDelegationMode -Prompt $prompt) -ne 'preparation') } function Test-OrchestrationReady { @@ -244,6 +255,33 @@ function Get-CheckpointContent { return Get-Content -Raw -LiteralPath $script:CheckpointPath } +# The two per-mode read seams (issue #554). Each takes its path from the fixed mode +# table and never from a delegation's own text; an absent file returns an empty +# string, which the readiness predicate then treats as a deny. +function Get-EpicCheckpointContent { + [CmdletBinding()] + [OutputType([string])] + param() + + $path = Get-OrchestrationDelegationCheckpointPath -Mode 'epic' + if (-not (Test-Path -LiteralPath $path)) { + return '' + } + return Get-Content -Raw -LiteralPath $path +} + +function Get-ParallelCheckpointContent { + [CmdletBinding()] + [OutputType([string])] + param() + + $path = Get-OrchestrationDelegationCheckpointPath -Mode 'parallel' + if (-not (Test-Path -LiteralPath $path)) { + return '' + } + return Get-Content -Raw -LiteralPath $path +} + function Get-OrchestrationPreimplementationGateAllowDecision { [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] @@ -274,6 +312,23 @@ function Get-OrchestrationPreimplementationGateBlockDecision { } } +# Builds a mode-specific deny reason naming the checkpoint actually consulted and the +# predicate that failed, behind the unchanged PREIMPLEMENTATION_GATE_BLOCKED prefix +# that downstream reason-matching reads. +function Get-OrchestrationModeDenyReason { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)][string] $Mode, + [Parameter(Mandatory)][string] $Failure + ) + + $path = Get-OrchestrationDelegationCheckpointPath -Mode $Mode + return ("PREIMPLEMENTATION_GATE_BLOCKED: this $Mode-mode delegation was evaluated against " + + "$path, and the failed readiness predicate is '$Failure'. Implementation operations " + + 'require that checkpoint to satisfy every readiness predicate before implementation begins.') +} + function Invoke-OrchestrationPreimplementationGateDecision { [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] @@ -282,7 +337,21 @@ function Invoke-OrchestrationPreimplementationGateDecision { [AllowEmptyString()] [string] $ToolInputRaw, - [string] $CheckpointRaw + [string] $CheckpointRaw, + + # The two per-mode injection parameters (issue #554, decision D2). Each + # overrides its read seam whenever the caller BINDS it, decided with + # ContainsKey and never with a truthiness test, so an explicitly supplied + # empty string suppresses the seam instead of falling through to disk. The + # two parameters above keep their existing names, positions, attributes, + # and truthiness-based fall-through exactly. + [AllowNull()] + [AllowEmptyString()] + [string] $EpicCheckpointRaw, + + [AllowNull()] + [AllowEmptyString()] + [string] $ParallelCheckpointRaw ) $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw @@ -296,6 +365,12 @@ function Invoke-OrchestrationPreimplementationGateDecision { $toolInput = $payload.Value $requiresReadyCheckpoint = $false + # The path and command legs are single-feature by construction; only the + # delegation leg carries a mode marker, so only it can move the mode off the + # default. Both other legs therefore keep the default readiness source and the + # default wording they have today. + $mode = $script:OrchestrationDelegationDefaultMode + $prompt = '' $filePath = Get-StringProperty -Value $toolInput -Name 'file_path' if ($filePath) { $normalized = ([string]$filePath) -replace '\\', '/' @@ -306,6 +381,10 @@ function Invoke-OrchestrationPreimplementationGateDecision { $requiresReadyCheckpoint = Test-ImplementationCommand -Command $command } else { $requiresReadyCheckpoint = Test-ImplementationDelegation -ToolInput $toolInput + if ($requiresReadyCheckpoint) { + $prompt = Get-ClaudeHookToolInputString -ToolInput $toolInput -Name 'prompt' + $mode = Resolve-OrchestrationDelegationMode -Prompt $prompt + } } } @@ -313,6 +392,35 @@ function Invoke-OrchestrationPreimplementationGateDecision { return Get-OrchestrationPreimplementationGateAllowDecision } + # A prompt-declared checkpoint path is a cross-check operand only and never + # selects a source: a delegation that named its own readiness file would choose + # its own gate. Disagreement with the mode's canonical path is a deny. + if (-not (Test-OrchestrationDelegationDeclaredCheckpointPath -Prompt $prompt -Mode $mode)) { + return Get-OrchestrationPreimplementationGateBlockDecision -Reason ( + Get-OrchestrationModeDenyReason -Mode $mode -Failure 'declared-checkpoint-path') + } + + if ($mode -eq 'epic' -or $mode -eq 'parallel') { + $isEpic = ($mode -eq 'epic') + $injected = if ($isEpic) { 'EpicCheckpointRaw' } else { 'ParallelCheckpointRaw' } + $modeRaw = if ($PSBoundParameters.ContainsKey($injected)) { + [string]$PSBoundParameters[$injected] + } elseif ($isEpic) { Get-EpicCheckpointContent } else { Get-ParallelCheckpointContent } + try { + $modeCheckpoint = ConvertFrom-CheckpointJson -Json ([string]$modeRaw) + } catch { $modeCheckpoint = $null } + $folder = Find-OrchestrationDelegationTargetFolder -Prompt $prompt + $issue = Find-OrchestrationDelegationIssueNumber -Prompt $prompt + $failure = if ($isEpic) { + Get-EpicOrchestrationReadinessFailure -Checkpoint $modeCheckpoint -TargetFolder $folder -IssueNumber $issue + } else { + Get-ParallelOrchestrationReadinessFailure -Checkpoint $modeCheckpoint -TargetFolder $folder -IssueNumber $issue + } + if (-not $failure) { return Get-OrchestrationPreimplementationGateAllowDecision } + return Get-OrchestrationPreimplementationGateBlockDecision -Reason ( + Get-OrchestrationModeDenyReason -Mode $mode -Failure $failure) + } + if (-not $CheckpointRaw) { $CheckpointRaw = Get-CheckpointContent } diff --git a/.claude/rules/plan-acceptance-gates.md b/.claude/rules/plan-acceptance-gates.md index d35031e4c..ea905f5ec 100644 --- a/.claude/rules/plan-acceptance-gates.md +++ b/.claude/rules/plan-acceptance-gates.md @@ -36,10 +36,18 @@ Every finding string begins with the square-bracketed `P#-T#` identifier of the | **G4** | A `--cov` value supplied space-separated (`--cov `) 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** | +| **G7** | A write-mode command — one matching a write-mode register entry, that is a tool that rewrites tracked source and exits 0 after rewriting — whose attributed task text carries none of that entry's observation markers. The exit code alone cannot distinguish a clean run from a repairing one, so the acceptance condition holds either way. | **Warning** | +| **G8** | A `git diff` invocation carrying no non-flag ref operand and neither `--cached` nor `--staged`. It compares the worktree against the index, which is ambient state: the comparison passes vacuously once the change is committed. Exonerated when the attributed task text carries a second `git diff` or a `git status` span. | **Warning** | +| **G8b** | A `git diff` invocation carrying a non-flag ref operand together with `--name-only` or `--name-status`, whose attributed task text carries neither a `git add` span nor a `git status --porcelain` span. A name-listing diff enumerates tracked changes only, so a path the plan creates is invisible to it. | **Warning** | +| **G9** | A command carrying a `--cov` token, carrying no token beginning `--cov-report=term` and no token beginning `--cov-fail-under`, whose project `addopts` value also carries no `--cov-report=term`. No coverage table is printed, so a coverage number the acceptance condition demands can never be read. | **Warning** | + +G1 through G9 are the complete shipped set. G1 through G6 were added by issue #486 and G7 through G9 by issue #519; the parenthetical in this file's title names the original set only. 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. +G8b is a distinct rule from G8 and not a sub-case of it: G8 reports a diff with no ref operand, G8b reports one that has a ref operand but cannot observe an untracked path. A single invocation can satisfy only one of the two, because the presence of a ref operand decides between them. + +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. G7, G8, and G8b are likewise context-free and run on every invocation. G9 requires the repository seam, because it reads the project `addopts` value through it, so with no context supplied G9 does not run. All four of the rules added by issue #519 ship in the Warning channel, so none of them can alter the Blocking list at all. ### Attribution window @@ -55,7 +63,7 @@ A repository seam that raises, or that reports a non-zero exit, causes G2, G3, G 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 measurement is recorded in `docs/features/completed/2026-08-17-reject-unfalsifiable-acceptance-gates-in-atomic-plans-486/evidence/qa-gates/g5-corpus-measurement.2026-08-20T12-02.md`. That feature has since been completed and its folder moved out of the active tree, so the citation names the completed tree; the path this file previously carried, under `docs/features/active/`, no longer resolves. 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). @@ -75,6 +83,127 @@ Rejecting such a plan would block a correct plan on evidence about a state the p 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. +### The shared measurement behind G7, G8, G8b, and G9 + +The four severities below were fixed by one corpus measurement recorded in `docs/features/active/2026-08-23-plan-acceptance-gates-miss-unobservable-and-ambient-state-gates-519/evidence/qa-gates/corpus-measurement.2026-08-24T00-00.md`. The pre-declared decision rule was written and committed **before** the driver existed and before any count was taken, so the ordering is verifiable from git history rather than only asserted in prose. Its form matches the G5 precedent: the shipped severity is Blocking if and only if the total finding count for that rule is greater than zero **and** the recorded false-positive count for that rule is zero; otherwise Warning. + +The false-positive definition the rule consumes is likewise pre-declared: a finding is a false positive when the acceptance condition it reports is in fact falsifiable — when the plan states an observation sufficient to distinguish a passing run from a failing one, by a mechanism the rule's predicate does not recognise. A finding is a true positive when the acceptance condition it reports genuinely cannot fail, or can only be satisfied vacuously. + +The measurement scanned 194 plan files under `docs/features`, covering the `active`, `completed`, and `archive` trees. Unlike the G5 measurement, every rule here found findings, so each false-positive count is a count over an examined population rather than over an empty one. The vacuity declaration therefore applies to no rule of this set. + +| Rule | Corpus files | Candidates | Findings | True positives | False positives | Decision | Shipped | +| --- | --- | --- | --- | --- | --- | --- | --- | +| G7 | 194 | 519 | 466 | 444 | 22 | second conjunct fails | **Warning** | +| G8 | 194 | 237 | 82 | 75 | 7 | second conjunct fails | **Warning** | +| G8b | 194 | 47 | 19 | 19 | 0 | pre-declared unconditional clause | **Warning** | +| G9 | 194 | 273 | 8 | 4 | 4 | second conjunct fails | **Warning** | + +### G7 — ships as a Warning because the measurement recorded 22 false positives + +G7's finding count of 466 satisfies the first conjunct, so the rule was not decided by a vacuous measurement. It fails the second conjunct: 22 of the 466 findings are false positives, in two classes named in full in the measurement artifact. + +**Class 1, two findings — read-only argv shape.** The `prettier-write` register entry matches the argv shape `npm run format` and declares no exclusion for a check flag, so a check-mode invocation written as `npm run format -- --check` is matched as if it wrote. That command does not write and exits non-zero on drift, so its acceptance condition is falsifiable by the exit code alone. + +**Class 2, twenty findings — the task observes the tree rather than the tool's stdout.** The attributed task text carries a `git status --porcelain` or `git status` span, so the plan distinguishes a clean run from a repairing one by comparing the tree before and after. G7's marker set recognises tool-output observation only, so it does not see this mechanism. Class 2 is the larger of the two and is the reason the rule cannot ship Blocking on this measurement: a plan that observes the tree has stated a real acceptance condition, and rejecting it would block a correct plan. + +Both classes are addressable by a later feature — Class 1 by adding a check-flag exclusion to the `prettier-write` entry, Class 2 by admitting a tree-observation span as an alternative to a marker — but neither was changed here, because narrowing a predicate after reading its measurement would invalidate the measurement that decided its severity. A later feature that narrows either class must re-take the measurement the same way. + +Counts, and every one of the 22 false positives named by plan path, task identifier, and offending span: `docs/features/active/2026-08-23-plan-acceptance-gates-miss-unobservable-and-ambient-state-gates-519/evidence/qa-gates/corpus-measurement.2026-08-24T00-00.md`. + +### G8 — ships as a Warning because the measurement recorded 7 false positives + +G8's finding count of 82 satisfies the first conjunct. It fails the second: 7 findings are false positives, in two classes. + +**Class 1, six findings — `--no-index`.** `git diff --no-index` compares two named paths on disk. It does not compare the worktree against the index, so G8's stated claim that the comparison passes vacuously once the change is committed is false for this form. + +**Class 2, one finding — the unmerged-path filter.** `--diff-filter=U` selects conflicted paths during a merge or rebase. In that state the worktree-against-index comparison is the correct one and does not become vacuous on commit, because the conflict is precisely what is not yet committed. + +Counts, and every one of the 7 false positives named by plan path, task identifier, and offending span: `docs/features/active/2026-08-23-plan-acceptance-gates-miss-unobservable-and-ambient-state-gates-519/evidence/qa-gates/corpus-measurement.2026-08-24T00-00.md`. + +### G8b — ships as a Warning unconditionally, by the pre-declared rule + +G8b is exempt from the two-conjunct rule and **cannot reach the Blocking channel by any measured outcome**. That exemption was declared before the counts were taken, and it holds even though G8b is the only rule of the four whose measured false-positive count is zero. + +The reason is that G8b carries the highest false-positive surface of the set, and a false-positive count taken over one corpus does not bound the false-positive surface of a predicate. Promoting a rule on the strength of a clean run over one corpus would convert an accident of that corpus into a gate. The 19 findings were nonetheless examined individually and all 19 were retained as true positives, across three sub-classes: a placeholder ref operand, which resolves to a real ref at run time and still cannot make the diff see an untracked path; an artifact-field label inside the span, which is cosmetic and leaves the underlying acceptance condition as the finding describes it; and a pathspec written without the `--` separator, where the predicate reads the pathspec as a ref operand. That last sub-class is a true positive for G8b and is simultaneously recorded in the measurement as a **G8 false negative**: the same span should also have been reported by G8, and was not. + +Counts, the zero false-positive record, and the three sub-classes with their plan paths and offending spans: `docs/features/active/2026-08-23-plan-acceptance-gates-miss-unobservable-and-ambient-state-gates-519/evidence/qa-gates/corpus-measurement.2026-08-24T00-00.md`. + +### G9 — ships as a Warning because the measurement recorded 4 false positives + +G9's finding count of 8 satisfies the first conjunct. It fails the second: 4 of the 8 findings are false positives, and all four share one cause — the offending span is not a command the plan states as an acceptance condition. Two are prose quotations of a flag or flag pair used to describe something the task declares out of scope; one is a test-data literal inside a sentence asserting a helper's return values; and one is a truncated restatement of a command whose full form on the task line does supply a terminal reporter. In every one of the four, the acceptance condition the task actually states is decided by a pass count, an exit code, or an artifact field, not by a coverage number. + +The remaining 4 findings are true positives, and each is the exact defect the rule was written for: the acceptance condition demands a numeric coverage percentage, the command supplies no terminal reporter, the project `addopts` supplies only an LCOV reporter, and the number the acceptance demands is therefore never printed. + +Counts, and every one of the 4 false positives named by plan path, task identifier, offending span, and the reason its acceptance condition is falsifiable: `docs/features/active/2026-08-23-plan-acceptance-gates-miss-unobservable-and-ambient-state-gates-519/evidence/qa-gates/corpus-measurement.2026-08-24T00-00.md`. + +## The Write-Mode Register + +G7 reads a fixed register of six entries. Each entry is an argv predicate plus a set of observation markers matched case-sensitively as substrings of the owning task's attributed text. The register is data, not code, so the same six entries are transcribed into the TypeScript twin without porting behaviour. + +### Membership criterion + +**A tool belongs in the write-mode register when it rewrites tracked source and still exits 0 after rewriting.** That conjunction is the whole criterion. The exit code of such a tool is the same on a clean run and on a repairing run, so a plan that states the invocation as an acceptance condition and records nothing but the exit code has asserted nothing the tool can fail. The register's markers name the literals the tool's success-case output prints, which is the observation that separates the two runs. + +The six entries are `black-write`, `ruff-fix`, `prettier-write`, `poshqc-format`, `run_poshqc_analyze_autofix`, and `poshqc-suite`. The fifth is named for the MCP tool `run_poshqc_analyze_autofix`, whose argv predicate is an argv word ending with that name. + +### Executable-position constraint + +A word satisfies an entry's argv shape only when its index lies within the leading four-word scan window and the word immediately preceding it does not begin with a hyphen. A tool name supplied as the operand of a search flag is therefore never read as an invocation, which mirrors the extractor's existing rule that a grep-family name appearing as an operand is not the executable. Without this constraint a task that searches a policy file for a register member's name would report a finding against its own search command — including a task that searches this file. + +### Exclusions, with the reason for each + +Two tools that do write are deliberately **not** register members. + +- **`git add` is excluded.** A plan stages in order to make a later diff complete, so its acceptance concerns the diff, not the staging. The staging itself carries no acceptance condition to make unfalsifiable, and G8b already reads a `git add` span as an exonerating companion rather than as an offence. +- **`npm ci` is excluded.** Its only write target is git-ignored, so it rewrites no tracked source and fails the membership criterion's first half. A plan can still state a real observation of it — an installed-package count, or the existence of a resolved binary — but it is not a case G7 needs to report. + +### Two writers that are not register members and are not exclusions + +Two further tools write files without being register members, and they are recorded here so a later maintainer does not read their absence as an oversight. + +- **The Python test runner** writes coverage output under the artifacts tree, for example the LCOV file the project `addopts` value names. It rewrites no tracked source, and its exit code already distinguishes a passing run from a failing one, so it fails the membership criterion on both halves. +- **The PoshQC test tool** likewise writes result and coverage files under the artifacts tree without rewriting tracked source, and likewise reports pass and fail through its own result. The three PoshQC entries that *are* members — format, analyze-autofix, and the suite — are members because they rewrite tracked PowerShell source in place. + +A tool that writes only under the artifacts tree is therefore outside the register by construction. The register is about tracked source, not about writing in general. + +### Known false-negative class — the single-token tool-name span + +The command extractor drops any command span shorter than two shell words. That two-word minimum-argv floor predates these rules, is pinned by an existing test, and was deliberately left unchanged: relaxing it would newly admit a single-word coverage-argument span into the G1 and G4 scan, which would change existing output, and changing existing output is exactly what this addition forbids itself. + +The consequence is a false-negative class. **A tool invoked as a bare single-token name is never extracted, so G7 can never report it, however unobservable its acceptance condition is.** This is the form plans commonly use for MCP tools, including the PoshQC formatter, the PoshQC analyzer autofix tool, and the PoshQC composite suite tool — three of the six register entries. Those three entries are reachable only by a span whose first word is the tool name and whose second word is an operand, for example the tool name followed by a path. + +This is a stated limitation, not a promise deferred, and it is recorded here for the same reason the placeholder guard's false-negative class is recorded above: it is the cost side of a trade, and a later feature must weigh it rather than rediscover it. The limitation is pinned by a test asserting that a single-token tool-name span produces zero findings from every one of the four rules, so the boundary cannot move silently. + +A later feature may revisit the floor, but only together with a plan for the coverage-rule output change that relaxing it would cause. The two cannot be separated: the floor is one value read by one extractor feeding every rule, so any relaxation reaches G1 and G4 as well as G7. + +## Deliberately Uncovered Sub-Classes + +Issue #519 measured five classes of unfalsifiable acceptance condition on a single plan. G7 and G9 cover two decidable slices of the first, G8 and G8b cover the third. The remainder is recorded here so a reviewer knows what these rules do **not** reach and that human attention is still required for it. + +### The general unobservable-success-output class, beyond what G7 and G9 reach + +The class is: an acceptance condition that asserts over output the command does not emit on a successful run. G9 covers the slice where a coverage command prints no table at all, and G7 covers the slice where a write-mode tool's exit code is identical on a clean and a repairing run. Neither reaches the general case. + +Three measured instances outside their reach illustrate why. A gate requiring a formatter's `reformatted` line to be recorded verbatim is unsatisfiable on a clean run, because that line is printed only when a file was rewritten. A gate demanding a zero-diagnostic count from an analyzer that returns only an ok flag and a one-sentence summary names a value that has no source. A gate demanding separate line and branch percentages from a coverage run that prints one combined `Cover` column reads two numbers that are never printed. + +**Deciding these requires knowing each tool's success-case output, which is not derivable from the plan text, the rules, or the tool documentation.** That is what makes the class invisible to review: four careful review cycles on the measured plan missed it entirely, and it surfaced only when the reviewer ran the tools and observed what they actually printed. A rule would need a per-tool output model, which is a different mechanism from a register of argv predicates. The class is therefore addressed by an authoring requirement in `.claude/skills/atomic-plan-contract/SKILL.md` — that a plan author observe a command's success-case output before asserting over that output — and not by a rule. + +### The task-ordering class + +The class is: an acceptance condition that is unsatisfiable because of where its task sits in the plan rather than because of what the command does. A gate that runs a test path containing deliberately-failing cases added by an earlier task, before the later task that makes them pass, cannot exit 0. A baseline captured *after* a write-mode formatter has already repaired pre-existing drift becomes either a blanket waiver or makes the later gate unsatisfiable. + +**No rule covers this class.** Detecting it requires intra-plan dependency reasoning across phases: the validator would have to model which task changes which file, and which later assertion depends on that change. That is a different analysis from the per-command predicates these rules apply. It is recorded here so a reviewer knows the class still needs human attention. + +### The executor-choice heuristic — rejected, and closed rather than deferred + +The proposal was a rule that flags selection vocabulary in acceptance text — "any", "a suitable", "the known", "choose" — on the reasoning that an executor free to pick the evidence it is judged against cannot fail. The underlying concern is real and was measured: two conditions on the examined plan asked the executor to identify "the known-genuine pair" and to choose a survivor list. + +**The rule is rejected.** The vocabulary it would scan for is ordinary plan prose used in roles that carry no selection semantics, and the research recorded a corpus instance in which the word "any" appears inside a *prohibition* rather than a selection — a case the scan would report and the author would be right to ignore. A keyword scan over prose is not statically decidable for the property the rule would claim to detect. This file's own guidance is to weigh a new rule on its authoring-time false-positive rate rather than on how many committed plans it would have flagged, and on that test the proposal fails. + +The concern is addressed as authoring guidance in `.claude/skills/atomic-plan-contract/SKILL.md` instead, where a false positive costs an author a moment's judgment rather than a blocked plan. + +**This is closed, not deferred.** Reviving it requires new evidence about its false-positive rate on plan prose, not a restatement of the original proposal. + ## 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. diff --git a/.claude/skills/atomic-plan-contract/SKILL.md b/.claude/skills/atomic-plan-contract/SKILL.md index f7cb98e2b..e3b2198e4 100644 --- a/.claude/skills/atomic-plan-contract/SKILL.md +++ b/.claude/skills/atomic-plan-contract/SKILL.md @@ -159,6 +159,8 @@ Before a plan can be treated as approved: 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. +The same call additionally applies the rules G7, G8, G8b, and G9, which report a write-mode command observed only by its exit code, an unanchored `git diff`, a name-listing diff with no companion span, and a coverage command that prints no table. All four ship in the Warning channel, so they surface without failing the gate. The complete shipped set is therefore G1 through G9. + ## 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. @@ -171,6 +173,13 @@ Rules: - **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 ` 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. +- **Record an observation beyond the exit code for a write-mode command.** A formatter or a fixing linter rewrites tracked source and still exits 0 after rewriting, so its exit code is identical on a clean run and on a repairing one. State, in the task text, the literal its success-case output prints — for example the summary line a formatter prints when it changed nothing — or state a before-and-after tree observation. Rule G7 in `.claude/rules/plan-acceptance-gates.md` reports a write-mode command whose task text carries neither. +- **Anchor every `git diff` to a ref.** A `git diff` with no ref operand and no `--cached` compares the worktree against the index, which is ambient state: it passes vacuously once the change is committed, so the assertion cannot fail for the executor who commits before running it. Supply an explicit ref operand, usually the base branch. Rule G8 reports the unanchored form. +- **Pair a name-listing diff with a staging or porcelain-status companion.** An anchored `git diff --name-only` or `--name-status` enumerates tracked changes only, so it can never report a file the task creates, and an assertion that it lists newly created files always sees an empty list. Add a `git add` span or a `git status --porcelain` span in the same task. Rule G8b reports a name-listing diff with neither companion. The two mechanisms are complementary and each alone is wrong in one state: the anchored diff is blind to untracked files, and porcelain status goes empty once the change is committed. +- **Pass a terminal reporter to every coverage command.** The project `addopts` value supplies an LCOV reporter only, so a coverage command that does not pass `--cov-report=term-missing` prints no coverage table at all, and a numeric percentage the acceptance condition demands can never be read from it. Rule G9 reports a coverage command that supplies no terminal reporter and no `--cov-fail-under` threshold. +- **Observe a command's success-case output before asserting over that output (mandatory).** Run the command, or read a recorded run of it, and confirm the value you intend to assert is actually printed on a *successful* run. Do not infer it from the tool's documentation, from the plan, or from what the tool prints on failure. This requirement exists because the class of defect it prevents is not detectable by reading: an assertion on a line the tool prints only when it changed something is unsatisfiable on a clean run; a demand for a zero-diagnostic count from a tool that returns only an ok flag names a value with no source; a demand for separate line and branch percentages from a coverage run that prints one combined column reads two numbers that are never printed. G7 and G9 cover two decidable slices of this class. The remainder is covered by this requirement and by nothing else, so a reviewer cannot rely on the gate to catch it. +- **Fix the evidence in the plan; never leave the executor to select it.** An executor free to choose the evidence it is judged against cannot fail. Do not write an acceptance condition that asks the executor to identify "the known-genuine pair", to choose a survivor list, or to pick any suitable instance. Name the instances, or state the mechanical derivation that produces them, so a third party re-running it obtains the same set. This is authoring guidance and not a rule: a validator rule that scanned acceptance text for selection vocabulary was proposed and rejected, because that vocabulary is ordinary plan prose used in roles carrying no selection semantics and a keyword scan over prose is not statically decidable for the property it would claim to detect. The judgment is the author's, and it is not automated. `.claude/rules/plan-acceptance-gates.md` records the rejection and the reason for it. +- **Check that the task-ordering does not make the condition unsatisfiable.** No rule covers this. A gate that runs a test path containing deliberately-failing cases added by an earlier task, before the later task that makes them pass, cannot exit 0. A baseline captured after a write-mode formatter has already repaired pre-existing drift becomes either a blanket waiver or makes a later gate unsatisfiable. Read each acceptance condition against the state the plan will actually be in when its task runs. ## Plan-Path Continuity Contract (Mandatory) diff --git a/.codex/agents/commit-steward-c1.toml b/.codex/agents/commit-steward-c1.toml new file mode 100644 index 000000000..26ae04746 --- /dev/null +++ b/.codex/agents/commit-steward-c1.toml @@ -0,0 +1,22 @@ +name = "commit-steward-c1" +description = "Generate a high-signal conventional commit message for the current repository based on staged changes only." +model = "gpt-5.6-luna" +model_reasoning_effort = "low" + +developer_instructions = """ +You are a commit-message specialist. + +Use the following repo-local skill as the canonical workflow source: +- commit-message-conventions + +Core behavior: +- Generate an audit-quality Git commit message for the current repository. +- Use an explicitly supplied commit-context artifact when one is provided. +- Otherwise inspect the local staged state directly with non-destructive Git commands. +- Do not create the commit. Do not stage, unstage, or modify files. +- Treat the shared skill as the source of truth for commit classification, formatting, interpretation, and prohibitions. + +Final response contract: +- Output exactly one fenced `text` code block containing only the commit message. +- Do not include commentary, alternatives, or explanations outside the code block. +""" diff --git a/.codex/agents/commit-steward-c2.toml b/.codex/agents/commit-steward-c2.toml new file mode 100644 index 000000000..9d29e9c36 --- /dev/null +++ b/.codex/agents/commit-steward-c2.toml @@ -0,0 +1,22 @@ +name = "commit-steward-c2" +description = "Generate a high-signal conventional commit message for the current repository based on staged changes only." +model = "gpt-5.6-terra" +model_reasoning_effort = "medium" + +developer_instructions = """ +You are a commit-message specialist. + +Use the following repo-local skill as the canonical workflow source: +- commit-message-conventions + +Core behavior: +- Generate an audit-quality Git commit message for the current repository. +- Use an explicitly supplied commit-context artifact when one is provided. +- Otherwise inspect the local staged state directly with non-destructive Git commands. +- Do not create the commit. Do not stage, unstage, or modify files. +- Treat the shared skill as the source of truth for commit classification, formatting, interpretation, and prohibitions. + +Final response contract: +- Output exactly one fenced `text` code block containing only the commit message. +- Do not include commentary, alternatives, or explanations outside the code block. +""" diff --git a/.codex/agents/commit-steward-c3-elevated.toml b/.codex/agents/commit-steward-c3-elevated.toml new file mode 100644 index 000000000..997283dc9 --- /dev/null +++ b/.codex/agents/commit-steward-c3-elevated.toml @@ -0,0 +1,22 @@ +name = "commit-steward-c3-elevated" +description = "Generate a high-signal conventional commit message for the current repository based on staged changes only." +model = "gpt-5.6-sol" +model_reasoning_effort = "high" + +developer_instructions = """ +You are a commit-message specialist. + +Use the following repo-local skill as the canonical workflow source: +- commit-message-conventions + +Core behavior: +- Generate an audit-quality Git commit message for the current repository. +- Use an explicitly supplied commit-context artifact when one is provided. +- Otherwise inspect the local staged state directly with non-destructive Git commands. +- Do not create the commit. Do not stage, unstage, or modify files. +- Treat the shared skill as the source of truth for commit classification, formatting, interpretation, and prohibitions. + +Final response contract: +- Output exactly one fenced `text` code block containing only the commit message. +- Do not include commentary, alternatives, or explanations outside the code block. +""" diff --git a/.codex/agents/commit-steward-c3.toml b/.codex/agents/commit-steward-c3.toml new file mode 100644 index 000000000..84191fb22 --- /dev/null +++ b/.codex/agents/commit-steward-c3.toml @@ -0,0 +1,22 @@ +name = "commit-steward-c3" +description = "Generate a high-signal conventional commit message for the current repository based on staged changes only." +model = "gpt-5.6-terra" +model_reasoning_effort = "high" + +developer_instructions = """ +You are a commit-message specialist. + +Use the following repo-local skill as the canonical workflow source: +- commit-message-conventions + +Core behavior: +- Generate an audit-quality Git commit message for the current repository. +- Use an explicitly supplied commit-context artifact when one is provided. +- Otherwise inspect the local staged state directly with non-destructive Git commands. +- Do not create the commit. Do not stage, unstage, or modify files. +- Treat the shared skill as the source of truth for commit classification, formatting, interpretation, and prohibitions. + +Final response contract: +- Output exactly one fenced `text` code block containing only the commit message. +- Do not include commentary, alternatives, or explanations outside the code block. +""" diff --git a/.codex/agents/commit-steward-c4.toml b/.codex/agents/commit-steward-c4.toml new file mode 100644 index 000000000..e69320d07 --- /dev/null +++ b/.codex/agents/commit-steward-c4.toml @@ -0,0 +1,22 @@ +name = "commit-steward-c4" +description = "Generate a high-signal conventional commit message for the current repository based on staged changes only." +model = "gpt-5.6-sol" +model_reasoning_effort = "max" + +developer_instructions = """ +You are a commit-message specialist. + +Use the following repo-local skill as the canonical workflow source: +- commit-message-conventions + +Core behavior: +- Generate an audit-quality Git commit message for the current repository. +- Use an explicitly supplied commit-context artifact when one is provided. +- Otherwise inspect the local staged state directly with non-destructive Git commands. +- Do not create the commit. Do not stage, unstage, or modify files. +- Treat the shared skill as the source of truth for commit classification, formatting, interpretation, and prohibitions. + +Final response contract: +- Output exactly one fenced `text` code block containing only the commit message. +- Do not include commentary, alternatives, or explanations outside the code block. +""" diff --git a/.codex/agents/commit-steward.toml b/.codex/agents/commit-steward.toml index fb356d1c5..86e8c8824 100644 --- a/.codex/agents/commit-steward.toml +++ b/.codex/agents/commit-steward.toml @@ -1,5 +1,7 @@ name = "commit-steward" description = "Generate a high-signal conventional commit message for the current repository based on staged changes only." +model = "gpt-5.6-terra" +model_reasoning_effort = "high" developer_instructions = """ You are a commit-message specialist. diff --git a/.codex/agents/orchestrator-c1.toml b/.codex/agents/orchestrator-c1.toml index 6183d80cc..980270767 100644 --- a/.codex/agents/orchestrator-c1.toml +++ b/.codex/agents/orchestrator-c1.toml @@ -76,6 +76,13 @@ and reasoning effort. Spawn the generated deployment agent named by those receip standalone run whose ceiling is C3 uses Terra/High; C3 epic children and C3 work with a C4 sibling use Sol/High. Missing profiles or models block with `model_unavailable`; do not fall back silently. +The transaction must resolve independently before every normal nested `spawn_agent` call, and validate the generated profile name, model, reasoning effort, path, and SHA-256. Append the exact validated receipt, with a non-empty phase and delegation identifier, to `codex_model_routing_receipts` in the selected checkpoint that `SubagentStart` reads. The process must durably flush selected-checkpoint persistence before launch, then call `spawn_agent` only with the resolver-returned `deployment_agent`. + +Reject generic aliases, ambiguous checkpoints, failed profile validation, failed persistence, late +receipts, and invalid start attestations. When the start attestation returns `routing_valid: false`, +reject child output and mutations; do not relax the recorder, authority-store, mutation-gate, or +stop-gate enforcement. + The production-file result and the model result are independent. File count selects small typed engineer versus large orchestrator topology. The C1-C4 result selects the suffix and pinned model for that already-selected family; it must not change the topology. diff --git a/.codex/agents/orchestrator-c2.toml b/.codex/agents/orchestrator-c2.toml index 670c1fddf..61b3224cf 100644 --- a/.codex/agents/orchestrator-c2.toml +++ b/.codex/agents/orchestrator-c2.toml @@ -76,6 +76,13 @@ and reasoning effort. Spawn the generated deployment agent named by those receip standalone run whose ceiling is C3 uses Terra/High; C3 epic children and C3 work with a C4 sibling use Sol/High. Missing profiles or models block with `model_unavailable`; do not fall back silently. +The transaction must resolve independently before every normal nested `spawn_agent` call, and validate the generated profile name, model, reasoning effort, path, and SHA-256. Append the exact validated receipt, with a non-empty phase and delegation identifier, to `codex_model_routing_receipts` in the selected checkpoint that `SubagentStart` reads. The process must durably flush selected-checkpoint persistence before launch, then call `spawn_agent` only with the resolver-returned `deployment_agent`. + +Reject generic aliases, ambiguous checkpoints, failed profile validation, failed persistence, late +receipts, and invalid start attestations. When the start attestation returns `routing_valid: false`, +reject child output and mutations; do not relax the recorder, authority-store, mutation-gate, or +stop-gate enforcement. + The production-file result and the model result are independent. File count selects small typed engineer versus large orchestrator topology. The C1-C4 result selects the suffix and pinned model for that already-selected family; it must not change the topology. diff --git a/.codex/agents/orchestrator-c3-elevated.toml b/.codex/agents/orchestrator-c3-elevated.toml index 0b7b510f5..9bbc27076 100644 --- a/.codex/agents/orchestrator-c3-elevated.toml +++ b/.codex/agents/orchestrator-c3-elevated.toml @@ -76,6 +76,13 @@ and reasoning effort. Spawn the generated deployment agent named by those receip standalone run whose ceiling is C3 uses Terra/High; C3 epic children and C3 work with a C4 sibling use Sol/High. Missing profiles or models block with `model_unavailable`; do not fall back silently. +The transaction must resolve independently before every normal nested `spawn_agent` call, and validate the generated profile name, model, reasoning effort, path, and SHA-256. Append the exact validated receipt, with a non-empty phase and delegation identifier, to `codex_model_routing_receipts` in the selected checkpoint that `SubagentStart` reads. The process must durably flush selected-checkpoint persistence before launch, then call `spawn_agent` only with the resolver-returned `deployment_agent`. + +Reject generic aliases, ambiguous checkpoints, failed profile validation, failed persistence, late +receipts, and invalid start attestations. When the start attestation returns `routing_valid: false`, +reject child output and mutations; do not relax the recorder, authority-store, mutation-gate, or +stop-gate enforcement. + The production-file result and the model result are independent. File count selects small typed engineer versus large orchestrator topology. The C1-C4 result selects the suffix and pinned model for that already-selected family; it must not change the topology. diff --git a/.codex/agents/orchestrator-c3.toml b/.codex/agents/orchestrator-c3.toml index ab64d3e18..0af8bdd16 100644 --- a/.codex/agents/orchestrator-c3.toml +++ b/.codex/agents/orchestrator-c3.toml @@ -76,6 +76,13 @@ and reasoning effort. Spawn the generated deployment agent named by those receip standalone run whose ceiling is C3 uses Terra/High; C3 epic children and C3 work with a C4 sibling use Sol/High. Missing profiles or models block with `model_unavailable`; do not fall back silently. +The transaction must resolve independently before every normal nested `spawn_agent` call, and validate the generated profile name, model, reasoning effort, path, and SHA-256. Append the exact validated receipt, with a non-empty phase and delegation identifier, to `codex_model_routing_receipts` in the selected checkpoint that `SubagentStart` reads. The process must durably flush selected-checkpoint persistence before launch, then call `spawn_agent` only with the resolver-returned `deployment_agent`. + +Reject generic aliases, ambiguous checkpoints, failed profile validation, failed persistence, late +receipts, and invalid start attestations. When the start attestation returns `routing_valid: false`, +reject child output and mutations; do not relax the recorder, authority-store, mutation-gate, or +stop-gate enforcement. + The production-file result and the model result are independent. File count selects small typed engineer versus large orchestrator topology. The C1-C4 result selects the suffix and pinned model for that already-selected family; it must not change the topology. diff --git a/.codex/agents/orchestrator-c4.toml b/.codex/agents/orchestrator-c4.toml index 4edc522fb..779da3f3d 100644 --- a/.codex/agents/orchestrator-c4.toml +++ b/.codex/agents/orchestrator-c4.toml @@ -76,6 +76,13 @@ and reasoning effort. Spawn the generated deployment agent named by those receip standalone run whose ceiling is C3 uses Terra/High; C3 epic children and C3 work with a C4 sibling use Sol/High. Missing profiles or models block with `model_unavailable`; do not fall back silently. +The transaction must resolve independently before every normal nested `spawn_agent` call, and validate the generated profile name, model, reasoning effort, path, and SHA-256. Append the exact validated receipt, with a non-empty phase and delegation identifier, to `codex_model_routing_receipts` in the selected checkpoint that `SubagentStart` reads. The process must durably flush selected-checkpoint persistence before launch, then call `spawn_agent` only with the resolver-returned `deployment_agent`. + +Reject generic aliases, ambiguous checkpoints, failed profile validation, failed persistence, late +receipts, and invalid start attestations. When the start attestation returns `routing_valid: false`, +reject child output and mutations; do not relax the recorder, authority-store, mutation-gate, or +stop-gate enforcement. + The production-file result and the model result are independent. File count selects small typed engineer versus large orchestrator topology. The C1-C4 result selects the suffix and pinned model for that already-selected family; it must not change the topology. diff --git a/.codex/agents/orchestrator.toml b/.codex/agents/orchestrator.toml index 61a8129e9..e4302e736 100644 --- a/.codex/agents/orchestrator.toml +++ b/.codex/agents/orchestrator.toml @@ -76,6 +76,13 @@ and reasoning effort. Spawn the generated deployment agent named by those receip standalone run whose ceiling is C3 uses Terra/High; C3 epic children and C3 work with a C4 sibling use Sol/High. Missing profiles or models block with `model_unavailable`; do not fall back silently. +The transaction must resolve independently before every normal nested `spawn_agent` call, and validate the generated profile name, model, reasoning effort, path, and SHA-256. Append the exact validated receipt, with a non-empty phase and delegation identifier, to `codex_model_routing_receipts` in the selected checkpoint that `SubagentStart` reads. The process must durably flush selected-checkpoint persistence before launch, then call `spawn_agent` only with the resolver-returned `deployment_agent`. + +Reject generic aliases, ambiguous checkpoints, failed profile validation, failed persistence, late +receipts, and invalid start attestations. When the start attestation returns `routing_valid: false`, +reject child output and mutations; do not relax the recorder, authority-store, mutation-gate, or +stop-gate enforcement. + The production-file result and the model result are independent. File count selects small typed engineer versus large orchestrator topology. The C1-C4 result selects the suffix and pinned model for that already-selected family; it must not change the topology. diff --git a/.codex/config.toml b/.codex/config.toml index 1fbe6000b..e46e186c9 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -2,7 +2,7 @@ default_permissions = ":danger-full-access" [mcp_servers.drm-copilot] command = "npx" -args = ["-y", "@danmoisan/drm-copilot-mcp@1.1.1"] +args = ["-y", "@danmoisan/drm-copilot-mcp@1.1.5"] required = true enabled_tools = [ "collect_commit_context", diff --git a/.codex/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 b/.codex/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 new file mode 100644 index 000000000..fb1135aeb --- /dev/null +++ b/.codex/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 @@ -0,0 +1,477 @@ +<# +.SYNOPSIS + Mode dispatch and per-mode readiness predicates for the orchestration + preimplementation gate (Codex surface). +.DESCRIPTION + Normative contract: the issue #554 mode dispatch and readiness predicates. + This file owns the fixed mode table, the canonical checkpoint-path map, the + implementation-agent allow-list, mode resolution, the target-token finder, the + prompt-declared-path cross-check, and the epic and parallel readiness + predicates. It owns nothing else. + + The readiness source is resolved from the recognized mode marker through the + fixed table below, and NEVER from a path parsed out of a prompt: a delegation + that named its own readiness file would choose its own gate. A prompt-declared + path is a cross-check operand only. The posture follows the shipped precedents + enforce-epic-wave-barrier.ps1 and enforce-parallel-cohort-barrier.ps1. + + PURITY. This file is pure string and object logic, with no filesystem, + process, network, or environment access: it opens no file, probes no path, + issues no web request, launches no executable, and imports no module. Every + readiness predicate accepts an ALREADY-PARSED checkpoint object, or $null; the + per-mode read seams live in the main gate hook. It is a new sibling rather than + an addition to the issue #539 helpers file, whose header declares a different + normative contract and which lacks headroom under the 500-line cap; leaving + that file byte-untouched is the proof the #539 exemption is unchanged. +#> +[CmdletBinding()] +param() + +# --- Constant table 1: the fixed mode table -------------------------------------- +# Markers are reused verbatim from shipped contracts and hooks; do not invent them. +# The trailing-period asymmetry is deliberate: preparation markers are matched WITH +# their periods as the shipped gate hook does and an existing test pins, epic and +# parallel WITHOUT, as the two barrier hooks do, which makes the three hooks on the +# same Agent matcher agree. MatchCase carries the same asymmetry. Both forms are +# containment tests over the prompt, so neither is sensitive to edge whitespace. +# Rows evaluate in order, so preparation is first and exempts; all markers on a row +# must be present for that row to match. +$script:OrchestrationDelegationModeTable = @( + [pscustomobject]@{ + Mode = 'preparation' + Markers = @('Preparation mode: true.', 'route_id: preparation.') + MatchCase = $true + } + [pscustomobject]@{ Mode = 'epic'; Markers = @('Epic mode: true'); MatchCase = $false } + [pscustomobject]@{ Mode = 'parallel'; Markers = @('Parallel mode: true'); MatchCase = $false } +) + +# The mode a prompt carrying no recognized marker resolves to. +$script:OrchestrationDelegationDefaultMode = 'single-feature' + +# --- Constant table 2: the canonical checkpoint-path map ------------------------- +# Preparation is exempt and has no readiness source, expressed as an empty string +# so callers test it with one truthiness check. No value here is ever derived from +# prompt text. +$script:OrchestrationDelegationCheckpointPathMap = [ordered]@{ + 'preparation' = '' + 'epic' = 'artifacts/orchestration/epic-orchestrator-state.json' + 'parallel' = 'artifacts/orchestration/parallel-orchestrator-state.json' + 'single-feature' = 'artifacts/orchestration/orchestrator-state.json' +} + +# --- Constant table 3: the implementation-agent allow-list ----------------------- +# Exactly five members: the agent tokens carried over from the replaced seven-token +# regex, dropping only the two free-text tokens. Retaining atomic-executor and the +# four typed-engineer names is a hard invariant; pre-existing cases supply two of +# them and assert deny. +$script:OrchestrationImplementationAgentAllowList = @( + 'python-typed-engineer' + 'powershell-typed-engineer' + 'typescript-engineer' + 'csharp-typed-engineer' + 'atomic-executor' +) + +function Get-OrchestrationModeProperty { + <# + .SYNOPSIS + Reads a named property off an already-parsed object, or $null. The single + field-access seam here; never throws. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)][AllowNull()] $Value, + [Parameter(Mandatory)][string] $Name + ) + + if ($null -eq $Value) { return $null } + $properties = $null + try { + $properties = $Value.PSObject.Properties + } catch { + Write-Debug "Property probe failed for '$Name': $($_.Exception.Message)" + return $null + } + if ($null -eq $properties -or -not ($properties.Name -contains $Name)) { return $null } + return $properties[$Name].Value +} + +function Get-OrchestrationModeString { + <# + .SYNOPSIS + Reads a named property as a trimmed string, or an empty string. The name is + distinct from the gate hooks' Get-StringProperty so it cannot shadow it. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)][AllowNull()] $Value, + [Parameter(Mandatory)][string] $Name + ) + + $raw = Get-OrchestrationModeProperty -Value $Value -Name $Name + if ($null -eq $raw) { return '' } + return ([string]$raw).Trim() +} + +function Get-OrchestrationModeCollection { + <# + .SYNOPSIS + Reads a named property as an array, or an empty array. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)][AllowNull()] $Value, + [Parameter(Mandatory)][string] $Name + ) + + $raw = Get-OrchestrationModeProperty -Value $Value -Name $Name + if ($null -eq $raw) { return @() } + return @($raw) +} + +function Get-OrchestrationModeFolderBasename { + <# + .SYNOPSIS + Normalizes a feature_folder value to its bare basename. A record's value + may be a full path with a lifecycle prefix or a bare basename, so both + sides of every comparison are normalized, following the cohort-barrier hook. + #> + [CmdletBinding()] + [OutputType([string])] + param([AllowNull()][AllowEmptyString()][string] $Path) + + if (-not $Path) { return '' } + $normalized = ($Path -replace '\\', '/').TrimEnd('/') + if (-not $normalized) { return '' } + return ($normalized -split '/')[-1] +} + +function Resolve-OrchestrationDelegationMode { + <# + .SYNOPSIS + Resolves a delegation prompt to one of the four mode names. Reads nothing + but the supplied string; evaluates preparation, then epic, then parallel, + then the default. Null and empty prompts resolve to the default. No + checkpoint path is ever read out of a prompt. + #> + [CmdletBinding()] + [OutputType([string])] + param([AllowNull()][AllowEmptyString()][string] $Prompt) + + if (-not $Prompt) { return $script:OrchestrationDelegationDefaultMode } + + foreach ($row in $script:OrchestrationDelegationModeTable) { + $allPresent = $true + foreach ($marker in $row.Markers) { + $present = if ($row.MatchCase) { + $Prompt.Contains($marker) + } else { + $Prompt -like ('*' + $marker + '*') + } + if (-not $present) { + $allPresent = $false + break + } + } + if ($allPresent) { return $row.Mode } + } + return $script:OrchestrationDelegationDefaultMode +} + +function Get-OrchestrationDelegationCheckpointPath { + <# + .SYNOPSIS + Returns the canonical readiness source for a mode name, from the fixed + table and nowhere else. Preparation returns an empty string, as does an + unrecognized name, so no caller can manufacture a source. + #> + [CmdletBinding()] + [OutputType([string])] + param([AllowNull()][AllowEmptyString()][string] $Mode) + + if (-not $Mode -or -not $script:OrchestrationDelegationCheckpointPathMap.Contains($Mode)) { + return '' + } + return [string]$script:OrchestrationDelegationCheckpointPathMap[$Mode] +} + +function Test-OrchestrationImplementationAgent { + <# + .SYNOPSIS + Tests a subagent_type value against the implementation-agent allow-list. + #> + [CmdletBinding()] + [OutputType([bool])] + param([AllowNull()][AllowEmptyString()][string] $SubagentType) + + if (-not $SubagentType) { return $false } + return ($script:OrchestrationImplementationAgentAllowList -contains $SubagentType) +} + +function Find-OrchestrationDelegationTargetFolder { + <# + .SYNOPSIS + Resolves the target feature-folder basename out of a delegation prompt, + reusing the wave-barrier technique in shape: scan for slash-separated + docs/features/active/ tokens, longest unique match wins, a Markdown match + resolves to its parent, and the basename is returned. $null when no token + resolves, which the caller treats as a deny. + #> + [CmdletBinding()] + [OutputType([string])] + param([AllowNull()][AllowEmptyString()][string] $Prompt) + + if (-not $Prompt) { return $null } + + $pattern = 'docs[\\/]+features[\\/]+active[\\/]+[^\s"''`]+' + $matchList = [regex]::Matches($Prompt, $pattern) + if ($matchList.Count -eq 0) { return $null } + + $unique = [ordered]@{} + foreach ($item in $matchList) { $unique[$item.Value] = $true } + $candidates = @(@($unique.Keys) | Sort-Object -Property Length -Descending) + $best = [string]$candidates[0] + + # Sentence punctuation trails a bare path token in every shipped kickoff + # contract. A trailing period is stripped only when it does not form the + # Markdown extension the next branch depends on. + $best = $best.TrimEnd(',', ';', ':') + while ($best.EndsWith('.') -and -not $best.EndsWith('.md')) { + $best = $best.Substring(0, $best.Length - 1) + } + if ($best -match '\.md$') { $best = $best -replace '[\\/][^\\/]+\.md$', '' } + + $basename = Get-OrchestrationModeFolderBasename -Path $best + if (-not $basename) { return $null } + return $basename +} + +function Find-OrchestrationDelegationIssueNumber { + <# + .SYNOPSIS + Resolves an issue number out of a delegation prompt, as a string. The + alternative target resolution of decision D3, issue_num being the primary + key on both checkpoints. The keyed form is preferred over the bare hash + form; $null when neither resolves. Accepted widening: a bare hash form such + as a pull-request reference can supply a number that is not the target's, + which widens the SEARCH only - an unmatched number yields no record and + denies, so deny-by-default is preserved. + #> + [CmdletBinding()] + [OutputType([string])] + param([AllowNull()][AllowEmptyString()][string] $Prompt) + + if (-not $Prompt) { return $null } + + $keyed = [regex]::Match($Prompt, 'issue[_-]?num(?:ber)?\s*[:=]\s*#?(\d+)', 'IgnoreCase') + if ($keyed.Success) { return $keyed.Groups[1].Value } + $hashForm = [regex]::Match($Prompt, '(?:^|\s)#(\d+)\b') + if ($hashForm.Success) { return $hashForm.Groups[1].Value } + return $null +} + +function Test-OrchestrationDelegationDeclaredCheckpointPath { + <# + .SYNOPSIS + Cross-checks a prompt-declared checkpoint path against the mode's canonical + path. True only when the prompt declares none for the mode, or declares one + equal to the canonical value. The declared value is a cross-check operand + ONLY and never selects a source; a disagreement is a deny. A mode with no + declared-path key returns true. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowNull()][AllowEmptyString()][string] $Prompt, + [AllowNull()][AllowEmptyString()][string] $Mode + ) + + if ($Mode -ne 'epic' -and $Mode -ne 'parallel') { return $true } + if (-not $Prompt) { return $true } + + $canonical = Get-OrchestrationDelegationCheckpointPath -Mode $Mode + $key = $Mode + '_checkpoint_path' + $declaredMatch = [regex]::Match( + $Prompt, [regex]::Escape($key) + '\s*[:=]\s*([^\s"''`]+)', 'IgnoreCase') + if (-not $declaredMatch.Success) { return $true } + + $declared = $declaredMatch.Groups[1].Value.TrimEnd(',', ';', ':') + while ($declared.EndsWith('.') -and -not $declared.EndsWith('.json')) { + $declared = $declared.Substring(0, $declared.Length - 1) + } + $declared = $declared -replace '\\', '/' + return ($declared -eq $canonical) +} + +function Test-OrchestrationModeTerminalMergeStatus { + <# + .SYNOPSIS + Tests whether a target record's merge_status is terminal-merged (decision + D8). The two terminal members are merged and worktree_removed, the same two + the barrier hooks treat as terminal-safe. Every other member, including the + failure members, is pre-merge here: re-delegation after a blocked or + conflicted state is legitimate remediation and must not be gated off. An + ABSENT merge_status is treated as not_started, per parallel invariant 7. + This predicate CONSUMES the existing member sets and extends neither. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)][AllowNull()] $Record) + + if ($null -eq $Record) { return $false } + $status = Get-OrchestrationModeString -Value $Record -Name 'merge_status' + if (-not $status) { return $false } + return (@('merged', 'worktree_removed') -contains $status) +} + +function Find-OrchestrationModeRecord { + <# + .SYNOPSIS + Finds the target record in a checkpoint's feature or item collection, + matching the normalized feature_folder basename first and issue_num second. + $null when neither resolves, which is a failed conjunct. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)][AllowNull()] $Records, + [AllowNull()][AllowEmptyString()][string] $TargetFolder, + [AllowNull()][AllowEmptyString()][string] $IssueNumber + ) + + if ($null -eq $Records) { return $null } + foreach ($record in @($Records)) { + if ($null -eq $record) { continue } + if ($TargetFolder) { + $folder = Get-OrchestrationModeString -Value $record -Name 'feature_folder' + $basename = Get-OrchestrationModeFolderBasename -Path $folder + if ($basename -and $basename -eq $TargetFolder) { return $record } + } + if ($IssueNumber) { + $issue = Get-OrchestrationModeString -Value $record -Name 'issue_num' + if ($issue -and $issue -eq $IssueNumber) { return $record } + } + } + return $null +} + +function Get-EpicOrchestrationReadinessFailure { + <# + .SYNOPSIS + Names the first failed epic readiness conjunct, or returns an empty string. + Accepts an already-parsed checkpoint object or $null and enforces, in + order: route_id exactly epic; non-empty epic_feature_folder; non-empty + epic_manifest_path under docs/features/epics/; non-empty + integration_branch; present and non-empty features; the resolved target + present as a record in features; and that record's merge_status neither + merged nor worktree_removed. The epic_manifest_path conjunct deliberately + tightens relative to validate_epic_orchestrator_state.py, whose + required-key set omits it, but is not stricter than the producing skill's + contract, which mandates it; a false deny names the failed conjunct. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)][AllowNull()] $Checkpoint, + [AllowNull()][AllowEmptyString()][string] $TargetFolder, + [AllowNull()][AllowEmptyString()][string] $IssueNumber + ) + + if ($null -eq $Checkpoint) { return 'checkpoint-absent' } + if ((Get-OrchestrationModeString -Value $Checkpoint -Name 'route_id') -ne 'epic') { + return 'route_id' + } + if (-not (Get-OrchestrationModeString -Value $Checkpoint -Name 'epic_feature_folder')) { + return 'epic_feature_folder' + } + $manifest = (Get-OrchestrationModeString -Value $Checkpoint -Name 'epic_manifest_path') -replace '\\', '/' + if (-not $manifest -or $manifest -notmatch '(^|/)docs/features/epics/') { + return 'epic_manifest_path' + } + if (-not (Get-OrchestrationModeString -Value $Checkpoint -Name 'integration_branch')) { + return 'integration_branch' + } + $features = Get-OrchestrationModeCollection -Value $Checkpoint -Name 'features' + if ($features.Count -eq 0) { return 'features' } + $record = Find-OrchestrationModeRecord -Records $features -TargetFolder $TargetFolder -IssueNumber $IssueNumber + if ($null -eq $record) { return 'target-record' } + if (Test-OrchestrationModeTerminalMergeStatus -Record $record) { return 'merge_status' } + return '' +} + +function Test-EpicOrchestrationReady { + <# + .SYNOPSIS + Boolean wrapper over Get-EpicOrchestrationReadinessFailure. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)][AllowNull()] $Checkpoint, + [AllowNull()][AllowEmptyString()][string] $TargetFolder, + [AllowNull()][AllowEmptyString()][string] $IssueNumber + ) + + $failure = Get-EpicOrchestrationReadinessFailure -Checkpoint $Checkpoint ` + -TargetFolder $TargetFolder -IssueNumber $IssueNumber + return (-not $failure) +} + +function Get-ParallelOrchestrationReadinessFailure { + <# + .SYNOPSIS + Names the first failed parallel readiness conjunct, or an empty string. + Accepts an already-parsed checkpoint object or $null and enforces, in + order: route_id exactly parallel; non-empty parallel_slug; non-empty + parallel_manifest_path; present and non-empty items; the resolved target + present as a record in items; and that record's merge_status neither merged + nor worktree_removed. It consumes the parallel item-state and merge-status + member sets and adds no member to either. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)][AllowNull()] $Checkpoint, + [AllowNull()][AllowEmptyString()][string] $TargetFolder, + [AllowNull()][AllowEmptyString()][string] $IssueNumber + ) + + if ($null -eq $Checkpoint) { return 'checkpoint-absent' } + if ((Get-OrchestrationModeString -Value $Checkpoint -Name 'route_id') -ne 'parallel') { + return 'route_id' + } + if (-not (Get-OrchestrationModeString -Value $Checkpoint -Name 'parallel_slug')) { + return 'parallel_slug' + } + if (-not (Get-OrchestrationModeString -Value $Checkpoint -Name 'parallel_manifest_path')) { + return 'parallel_manifest_path' + } + $items = Get-OrchestrationModeCollection -Value $Checkpoint -Name 'items' + if ($items.Count -eq 0) { return 'items' } + $record = Find-OrchestrationModeRecord -Records $items -TargetFolder $TargetFolder -IssueNumber $IssueNumber + if ($null -eq $record) { return 'target-record' } + if (Test-OrchestrationModeTerminalMergeStatus -Record $record) { return 'merge_status' } + return '' +} + +function Test-ParallelOrchestrationReady { + <# + .SYNOPSIS + Boolean wrapper over Get-ParallelOrchestrationReadinessFailure. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)][AllowNull()] $Checkpoint, + [AllowNull()][AllowEmptyString()][string] $TargetFolder, + [AllowNull()][AllowEmptyString()][string] $IssueNumber + ) + + $failure = Get-ParallelOrchestrationReadinessFailure -Checkpoint $Checkpoint ` + -TargetFolder $TargetFolder -IssueNumber $IssueNumber + return (-not $failure) +} diff --git a/config/orchestration-routing.json b/config/orchestration-routing.json index d859d440a..6b57f7ece 100644 --- a/config/orchestration-routing.json +++ b/config/orchestration-routing.json @@ -210,7 +210,7 @@ }, "model_budget": { "description": "Session-level model budget. fable_policy is a three-way switch controlling whether the fable tier is disabled (removed and clamped to opus), available (used as-is), or preferred (applies the preferred_overlay).", - "fable_policy": "preferred" + "fable_policy": "available" }, "codex_topology_policy": { "description": "Codex-native production-file-count routing. This axis selects the initial implementation topology independently from C1-C4 model selection.", @@ -346,6 +346,7 @@ "task-researcher", "prd-feature", "pr-author", + "commit-steward", "python-typed-engineer", "powershell-typed-engineer", "csharp-typed-engineer", diff --git a/scripts/bash/.gitkeep b/scripts/bash/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/bash/cleanup-worktrees.sh b/scripts/bash/cleanup-worktrees.sh new file mode 100644 index 000000000..efd1788fe --- /dev/null +++ b/scripts/bash/cleanup-worktrees.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# cleanup-worktrees.sh: thin CLI wrapper for the cleanup-merged-worktrees tool. It +# classifies local branches/worktrees against main and, in apply mode only, deletes +# delete-eligible candidates and drives consolidation. All logic lives in the two +# sourceable libraries so the functions are unit-testable from bats by sourcing them +# directly; this wrapper only resolves paths, prints usage, and dispatches. +set -euo pipefail + +# Resolve this script's own directory so the libraries source regardless of cwd. +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# The library paths are resolved at runtime from SCRIPT_DIR, so shellcheck cannot load +# them as static inputs without -x; SC1091 is the expected, benign result. The +# enumeration/protection library is sourced first because cleanup_worktrees_lib.sh's +# classification functions call cleanup_wt_git, parse_worktree_list, compute_protected, +# and normalize_wt_path defined there. +# shellcheck source=scripts/bash/cleanup_worktrees_enumerate_lib.sh +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/cleanup_worktrees_enumerate_lib.sh" +# shellcheck source=scripts/bash/cleanup_worktrees_lib.sh +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/cleanup_worktrees_lib.sh" +# shellcheck source=scripts/bash/cleanup_worktrees_actions_lib.sh +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/cleanup_worktrees_actions_lib.sh" + +usage() { + # Print the wrapper usage/help text. + cat <<'EOF' +Usage: cleanup-worktrees.sh [command] + +Commands: + (no args) | report Dry-run report (default). Classifies every local branch and + worktree against main and prints a deterministic, machine- + parseable report. Performs NO mutation of any kind. + --apply | apply Apply mode. Deletes delete-eligible candidates (MERGED_CLEAN, + MERGED_CONTENT_NEUTRAL, MERGED_EQUIVALENT) after same-process + re-verification, removing worktrees without force and deleting + branches with -D. Never acts on NOT_MERGED, + HAS_UNIQUE_RESIDUALS, or PROTECTED_CURRENT candidates. + --help | -h | help Print this help and exit 0. + +Report lines (pipe-delimited, LC_ALL=C ordered): BRANCH||; +COMMIT||||||; +WORKTREE|||; WARN|main-divergence||; +DIRTY||; ACTION||| (apply mode). + +Environment overrides: + CLEANUP_WT_GIT_BIN Path to the git binary; an empty or nonexistent value + is treated as missing (falls back to PATH git). This is + the test-stub seam. + CLEANUP_WT_STUB_SCENARIO Scenario directory consumed by the checked-in git stub + (tests only). + CLEANUP_WT_CONSOLIDATION_PATH Override the derived consolidation worktree path + (-wt/documentationandmemories). +EOF +} + +main() { + # Dispatch on the first argument. No args or `report` runs the dry-run report; + # `--apply`/`apply` runs apply mode; `--help`/`-h`/`help` prints usage and exits 0; + # anything else prints usage to stderr and exits 2 (usage-error parity with + # shell-qc.sh). Subcommand return codes are captured so an intermediate failure is + # never masked before the final exit. + local exit_code=0 + local command=${1:-} + case "$command" in + "" | report) + run_report || exit_code=$? + ;; + --apply | apply) + run_apply || exit_code=$? + ;; + --help | -h | help) + usage + return 0 + ;; + *) + usage >&2 + return 2 + ;; + esac + return "$exit_code" +} + +# Guard so the file can be sourced without executing main. main's return code is +# captured and re-exited explicitly as the final statement, so intermediate +# `|| rc=$?` captures inside the libraries cannot mask a real failure. +if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then + rc=0 + main "$@" || rc=$? + exit "$rc" +fi diff --git a/scripts/bash/cleanup_worktrees_actions_lib.sh b/scripts/bash/cleanup_worktrees_actions_lib.sh new file mode 100644 index 000000000..057529670 --- /dev/null +++ b/scripts/bash/cleanup_worktrees_actions_lib.sh @@ -0,0 +1,382 @@ +#!/usr/bin/env bash +# cleanup_worktrees_actions_lib.sh: sourceable function library for the mutating +# ("apply mode") half of the cleanup-worktrees tool. Provides consolidation onto the +# documentationandmemories branch (dedicated worktree creation, cherry-picking with +# conflict/empty handling, abort cleanup), the post-merge verification gate, and the +# deletion mechanics (same-process re-verification, no-force worktree removal, +# branch deletion) plus the apply-mode driver. +# +# Sourcing contract: this library depends on functions defined in +# scripts/bash/cleanup_worktrees_lib.sh (cleanup_wt_git, parse_worktree_list, +# classify_ancestry, classify_branch, run_report). The wrapper +# scripts/bash/cleanup-worktrees.sh sources that library first; the bats suites do +# the same. This file defines functions only and runs nothing at source time. +# git commands that legitimately return non-zero (cherry-pick on conflict, worktree +# remove on a dirty tree, merge-base --is-ancestor) are captured with `|| rc=$?` so +# an intended non-zero exit does not abort under set -euo pipefail. All consolidation +# git commands target the dedicated worktree via `git -C`, never the caller's tree. +# +# Guarded-read invariant: every git-backed read whose output or exit code is +# authoritative is captured in the parent shell (`out=$(cmd) || rc=$?`, iterated over +# `<<<"$out"`), never via `mapfile`/`< <(...)`. consolidation_worktree_path fails +# (non-zero, empty stdout) on a parse_worktree_list hard failure or an empty main path, +# so the malformed `-wt/documentationandmemories` derivation is impossible; both +# consumers (create_consolidation_worktree, cleanup_consolidation_on_abort) guard the +# capture. run_apply captures parse_worktree_list, enumerate_branches, and each +# classify_branch with rc up front and returns non-zero on any hard failure without +# mutating. reverify_delete_eligible and remove_worktree_safe capture their git-backed +# reads and fail closed (BLOCKED-REVERIFY / BLOCKED-DIRTY). Accepted best-effort sites +# (Design Decision 9), intentionally left as `|| true` because every failure direction +# is already fail-closed: `git fetch origin main` in verify_consolidation_merged (a +# stale main can only block deletion, never fabricate ancestry), `cherry-pick +# --skip`/`--abort` in cherry_pick_candidates (a failed skip/abort surfaces on the next +# pick as rc=1), the CHERRY_PICK_HEAD and ref-exists probes, and +# `vout=$(verify_consolidation_merged) || true` in run_apply (only the exact token +# MERGED_CLEAN unlocks deletion). + +CLEANUP_WT_CONSOLIDATION_BRANCH="documentationandmemories" + +consolidation_worktree_path() { + # Echo the consolidation worktree path. CLEANUP_WT_CONSOLIDATION_PATH overrides; + # otherwise the path is -wt/documentationandmemories, where the + # main worktree path is the first `git worktree list --porcelain` stanza. + # + # A parse_worktree_list hard failure aborts with git's exit code and no stdout; an + # empty main worktree path aborts with return 1 and no stdout. Either way the + # malformed `-wt/documentationandmemories` derivation from an empty main path is + # impossible. + local override=${CLEANUP_WT_CONSOLIDATION_PATH:-} + if [[ -n $override ]]; then + printf '%s\n' "$override" + return 0 + fi + local out rc=0 first_record main_wt="" + # Guarded parent-shell capture instead of `mapfile < <(...)`, which would swallow a + # git hard failure and yield a malformed path. + out=$(parse_worktree_list) || rc=$? + if ((rc != 0)); then + printf 'cleanup-worktrees: cannot derive consolidation path: git worktree list failed (rc=%s)\n' "$rc" >&2 + return "$rc" + fi + first_record=${out%%$'\n'*} + main_wt=${first_record%%|*} + if [[ -z $main_wt ]]; then + printf 'cleanup-worktrees: cannot derive consolidation path: empty main worktree path\n' >&2 + return 1 + fi + printf '%s-wt/%s\n' "$main_wt" "$CLEANUP_WT_CONSOLIDATION_BRANCH" +} + +create_consolidation_worktree() { + # Create the documentationandmemories branch in a dedicated worktree off main. + # + # Precondition: refs/heads/documentationandmemories must NOT already exist. If it + # does (a prior aborted run), stop and report to stderr rather than reuse silently. + # Otherwise run `git worktree add -b documentationandmemories main` with the + # path derived by consolidation_worktree_path. + # + # Returns 0 on success (emits ACTION|worktree-add||OK); non-zero on a + # consolidation-path derivation failure, the pre-existing-branch guard, or a failed + # worktree add. + local path rc=0 prc=0 + # Guarded capture: a consolidation-path derivation failure aborts before the ref + # probe and before any git mutation. + path=$(consolidation_worktree_path) || prc=$? + if ((prc != 0)); then + return "$prc" + fi + if cleanup_wt_git rev-parse --verify --quiet \ + "refs/heads/$CLEANUP_WT_CONSOLIDATION_BRANCH" >/dev/null 2>&1; then + printf 'cleanup-worktrees: refs/heads/%s already exists; refusing to reuse a prior consolidation branch. Remove it or resume the prior run.\n' \ + "$CLEANUP_WT_CONSOLIDATION_BRANCH" >&2 + return 1 + fi + cleanup_wt_git worktree add "$path" -b "$CLEANUP_WT_CONSOLIDATION_BRANCH" main || rc=$? + if ((rc != 0)); then + printf 'ACTION|worktree-add|%s|FAILED\n' "$path" + return "$rc" + fi + printf 'ACTION|worktree-add|%s|OK\n' "$path" + return 0 +} + +cherry_pick_candidates() { + # Cherry-pick each UNIQUE candidate onto the consolidation worktree. + # + # Reads COMMIT|||UNIQUE|... records from stdin. Source branches are + # processed in LC_ALL=C order and commits oldest-first within each branch; that + # ordering is inherited from run_report's COMMIT emission (enumerate_branches is + # LC_ALL=C sorted and select_cherry_pick_candidates emits oldest-first). One + # `git -C cherry-pick -x ` invocation per commit (never a multi-SHA + # invocation): -x records provenance and default cherry-pick preserves authorship. + # Every git command targets the consolidation worktree via -C, never the caller's + # worktree. Conflict/empty handling is implemented in this function (see below). + # + # Conflict handling (Planner Decision 4): on a non-zero cherry-pick whose result is + # a conflict (CHERRY_PICK_HEAD present), run `git -C cherry-pick --abort`, + # record the commit as CONFLICT, skip the remaining commits of that source branch + # (intra-branch dependency safety), and continue with the next branch. On a "now + # empty" result, run `git -C cherry-pick --skip` and reclassify the commit as + # droppable (CONTENT_ON_MAIN). Conflicts are never auto-resolved; --allow-empty and + # --keep-redundant-commits are never used. + # + # Args: $1 = consolidation worktree path. + local wt="$1" line branch sha prc cp_out rc=0 skip_branch="" + while IFS= read -r line; do + [[ $line == COMMIT\|* ]] || continue + IFS='|' read -r _ branch sha _ <<<"$line" + # A conflict on an earlier commit of this branch skips its remaining commits. + if [[ -n $skip_branch && $branch == "$skip_branch" ]]; then + printf 'ACTION|cherry-pick|%s|SKIPPED-BRANCH\n' "$sha" + continue + fi + prc=0 + # Capture combined output for reliable "now empty"/conflict detection (real + # git emits those diagnostics on stderr). The recording stub multiplexes its + # `stub-git:` argv log onto the same streams, so re-surface those lines to + # stderr for argv observation; this is a no-op under real git. + cp_out=$(cleanup_wt_git -C "$wt" cherry-pick -x "$sha" 2>&1) || prc=$? + if [[ $cp_out == *"stub-git: "* ]]; then + printf '%s\n' "$cp_out" | grep '^stub-git: ' >&2 || true + fi + if ((prc == 0)); then + printf 'ACTION|cherry-pick|%s|OK\n' "$sha" + continue + fi + # "Now empty" means the content already landed on main: skip and reclassify. + if printf '%s' "$cp_out" | grep -qi 'now empty'; then + cleanup_wt_git -C "$wt" cherry-pick --skip >/dev/null || true + printf 'COMMIT|%s|%s|CONTENT_ON_MAIN|reclassified-empty\n' "$branch" "$sha" + printf 'ACTION|cherry-pick-skip|%s|OK\n' "$sha" + continue + fi + # Otherwise a conflict: abort (never auto-resolve), record, skip the branch. + if cleanup_wt_git -C "$wt" rev-parse --verify --quiet CHERRY_PICK_HEAD >/dev/null 2>&1; then + cleanup_wt_git -C "$wt" cherry-pick --abort >/dev/null || true + fi + printf 'COMMIT|%s|%s|CONFLICT|\n' "$branch" "$sha" + printf 'ACTION|cherry-pick|%s|CONFLICT\n' "$sha" + skip_branch="$branch" + rc=1 + done + return "$rc" +} + +cleanup_consolidation_on_abort() { + # Tear down a partially-built consolidation on a hard failure: remove the + # consolidation worktree and delete the documentationandmemories branch, reporting + # each action. No silent leftovers are permitted. The forced-removal flag is never + # used (that policy is global to this library); a failed removal is reported. + # + # Returns 0 (best-effort cleanup; each step's result is reported via ACTION lines). + # When the consolidation path cannot be derived (a parse_worktree_list hard failure), + # the worktree removal is skipped and reported as ACTION|worktree-remove||FAILED (path + # unknown), and the branch deletion is still attempted (best-effort contract). + local path wrc=0 brc=0 prc=0 + # Guarded capture: on a path-derivation failure, skip the removal (path unknown) and + # continue to the branch-deletion step. + path=$(consolidation_worktree_path) || prc=$? + if ((prc != 0)); then + printf 'ACTION|worktree-remove||FAILED\n' + else + cleanup_wt_git worktree remove "$path" >/dev/null || wrc=$? + if ((wrc == 0)); then + printf 'ACTION|worktree-remove|%s|OK\n' "$path" + else + printf 'ACTION|worktree-remove|%s|FAILED\n' "$path" + fi + fi + cleanup_wt_git branch -D "$CLEANUP_WT_CONSOLIDATION_BRANCH" >/dev/null || brc=$? + if ((brc == 0)); then + printf 'ACTION|branch-delete|%s|OK\n' "$CLEANUP_WT_CONSOLIDATION_BRANCH" + else + printf 'ACTION|branch-delete|%s|FAILED\n' "$CLEANUP_WT_CONSOLIDATION_BRANCH" + fi + return 0 +} + +verify_consolidation_merged() { + # Post-merge gate: after `git fetch origin main`, is the consolidation branch an + # ancestor of main? Uses the same 0/1/>1 merge-base --is-ancestor handling as + # classify_ancestry. Exit 0 is the ONLY state that unlocks deletion of branches + # whose unique content was consolidated. Echoes MERGED_CLEAN / NOT_ANCESTOR / + # ANCESTRY_ERROR and returns 0 / 1 / 2 respectively. + local mrc=0 + cleanup_wt_git fetch origin main >/dev/null 2>&1 || true + cleanup_wt_git merge-base --is-ancestor "$CLEANUP_WT_CONSOLIDATION_BRANCH" main >/dev/null 2>&1 || mrc=$? + if ((mrc == 0)); then + printf 'MERGED_CLEAN\n' + return 0 + elif ((mrc == 1)); then + printf 'NOT_ANCESTOR\n' + return 1 + fi + printf 'ANCESTRY_ERROR\n' + return 2 +} + +reverify_delete_eligible() { + # Same-process re-verification immediately before a destructive action. + # + # Re-runs the classification ladder for (which re-checks ancestry against + # main, and the content-neutral/equivalence verdicts) and confirms the FRESH state + # is still delete-eligible: MERGED_CLEAN | MERGED_CONTENT_NEUTRAL | MERGED_EQUIVALENT. + # Any mismatch (the branch drifted, main moved, or a recorded verdict no longer + # holds) emits ACTION|delete||BLOCKED-REVERIFY and returns 1; otherwise 0. + # + # Args: $1 = branch name; $2 = recorded state (advisory, for callers' context). + local name="$1" line state="" out crc=0 + # Guarded parent-shell capture: a classify_branch hard failure (non-zero rc) maps + # explicitly to BLOCKED-REVERIFY/return 1, in addition to the state-token allowlist. + out=$(classify_branch "$name") || crc=$? + if ((crc != 0)); then + printf 'ACTION|delete|%s|BLOCKED-REVERIFY\n' "$name" + return 1 + fi + while IFS= read -r line; do + [[ $line == BRANCH\|* ]] || continue + IFS='|' read -r _ _ state <<<"$line" + break + done <<<"$out" + case "$state" in + MERGED_CLEAN | MERGED_CONTENT_NEUTRAL | MERGED_EQUIVALENT) + return 0 + ;; + *) + printf 'ACTION|delete|%s|BLOCKED-REVERIFY\n' "$name" + return 1 + ;; + esac +} + +remove_worktree_safe() { + # Remove a worktree without the forced-removal flag. A dirty worktree (modified + # tracked or any untracked files) makes `git worktree remove` fail; that blocks + # removal — the worktree's `git -C status --porcelain` output is reported as + # DIRTY| lines and the worktree is left in place. An empty path means the branch + # has no worktree: skip silently and return 0. The main worktree is never passed in. + # + # Args: $1 = worktree path (may be empty). + # Returns 0 on successful removal or no-worktree; 1 when a dirty worktree blocks it. + local path="$1" rc=0 line sout srrc=0 + [[ -z $path ]] && return 0 + cleanup_wt_git worktree remove "$path" >/dev/null || rc=$? + if ((rc == 0)); then + printf 'ACTION|worktree-remove|%s|OK\n' "$path" + return 0 + fi + # Guarded parent-shell capture of the diagnostic status read: a hard failure emits no + # DIRTY lines (the removal failure is already reported) but still blocks removal. + sout=$(cleanup_wt_git -C "$path" status --porcelain) || srrc=$? + if ((srrc == 0)) && [[ -n $sout ]]; then + while IFS= read -r line; do + [[ -z $line ]] && continue + printf 'DIRTY|%s|%s\n' "$path" "$line" + done <<<"$sout" + fi + printf 'ACTION|worktree-remove|%s|BLOCKED-DIRTY\n' "$path" + return 1 +} + +delete_branch() { + # Delete a branch with `git branch -D` (not -d). -d's merge check is HEAD-relative + # and this tool runs off-main, so -d would misfire; safety comes from the caller's + # preceding reverify_delete_eligible re-check against main. Emits an ACTION line. + # + # Args: $1 = branch name. + local name="$1" rc=0 + cleanup_wt_git branch -D "$name" >/dev/null || rc=$? + if ((rc == 0)); then + printf 'ACTION|branch-delete|%s|OK\n' "$name" + else + printf 'ACTION|branch-delete|%s|FAILED\n' "$name" + fi + return "$rc" +} + +delete_candidate() { + # Delete one candidate in the fixed order: + # 1. reverify_delete_eligible (same-process ancestry/equivalence re-check), + # 2. remove_worktree_safe (only when the candidate has a worktree), + # 3. delete_branch (git branch -D). + # Any step's failure stops the sequence for that candidate (a dirty worktree, for + # example, blocks the branch deletion because git refuses to delete a branch still + # checked out in a worktree). Prunable registrations are report-only; this tool + # never executes worktree pruning. + # + # Args: $1 = branch name, $2 = worktree path (may be empty), $3 = recorded state. + local name="$1" wt_path="$2" state="$3" + reverify_delete_eligible "$name" "$state" || return 1 + if [[ -n $wt_path ]]; then + remove_worktree_safe "$wt_path" || return 1 + fi + delete_branch "$name" +} + +run_apply() { + # Apply-mode driver. Emits the report (WORKTREE and per-branch BRANCH/COMMIT lines) + # and then performs deletion for delete-eligible states only. The eligible-state + # gate is a single explicit allowlist: MERGED_CLEAN | MERGED_CONTENT_NEUTRAL | + # MERGED_EQUIVALENT. NOT_MERGED, HAS_UNIQUE_RESIDUALS, PROTECTED_CURRENT, and + # ANCESTRY_ERROR never trigger a destructive action, and the main worktree is never + # a candidate (classify_branch marks it PROTECTED_CURRENT). Deletion of the + # consolidation branch (whose unique content was consolidated) is gated on + # verify_consolidation_merged() returning MERGED_CLEAN. Returns non-zero if any + # candidate's deletion failed or was blocked, or when the worktree listing, branch + # enumeration, or any branch classification hard-fails (in which case no mutation is + # performed for that failure). + local rc=0 name record wpath wbranch wflags cb_out state + local wlout wlrc=0 ebout ebrc=0 + # Guarded parent-shell captures up front, before check_main_freshness and any output: + # a hard failure of the worktree listing or branch enumeration aborts apply with git's + # exit code and performs no mutation. + wlout=$(parse_worktree_list) || wlrc=$? + if ((wlrc != 0)); then + return "$wlrc" + fi + ebout=$(enumerate_branches) || ebrc=$? + if ((ebrc != 0)); then + return "$ebrc" + fi + check_main_freshness + local -A wt_of=() + while IFS= read -r record; do + [[ -z $record ]] && continue + IFS='|' read -r wpath _ wbranch wflags <<<"$record" + printf 'WORKTREE|%s|%s|%s\n' "$wpath" "$wbranch" "$wflags" + [[ -n $wbranch && $wbranch != DETACHED ]] && wt_of[$wbranch]=$wpath + done <<<"$wlout" + # Consolidation merge gate: unlock the consolidation branch's own deletion only + # when documentationandmemories is merged into main. + local consolidation_ok=1 vout + if cleanup_wt_git rev-parse --verify --quiet \ + "refs/heads/$CLEANUP_WT_CONSOLIDATION_BRANCH" >/dev/null 2>&1; then + vout=$(verify_consolidation_merged) || true + [[ $vout == MERGED_CLEAN ]] && consolidation_ok=0 + fi + local crc + while read -r name _; do + [[ -z $name ]] && continue + if [[ $name == "$CLEANUP_WT_CONSOLIDATION_BRANCH" ]] && ((consolidation_ok != 0)); then + printf 'ACTION|delete|%s|BLOCKED-CONSOLIDATION-UNMERGED\n' "$name" + continue + fi + crc=0 + cb_out=$(classify_branch "$name") || crc=$? + printf '%s\n' "$cb_out" + if ((crc != 0)); then + # A branch classification hard failure never triggers deletion (its state is an + # error state, not on the allowlist) and propagates a non-zero driver rc. + rc=1 + continue + fi + state=$(printf '%s\n' "$cb_out" | awk -F'|' '/^BRANCH\|/{print $3; exit}') + case "$state" in + MERGED_CLEAN | MERGED_CONTENT_NEUTRAL | MERGED_EQUIVALENT) + delete_candidate "$name" "${wt_of[$name]:-}" "$state" || rc=1 + ;; + *) : ;; + esac + done <<<"$ebout" + return "$rc" +} diff --git a/scripts/bash/cleanup_worktrees_enumerate_lib.sh b/scripts/bash/cleanup_worktrees_enumerate_lib.sh new file mode 100644 index 000000000..3e4284381 --- /dev/null +++ b/scripts/bash/cleanup_worktrees_enumerate_lib.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# cleanup_worktrees_enumerate_lib.sh: sourceable enumeration/protection function +# group for the cleanup-worktrees tool, split out of cleanup_worktrees_lib.sh to keep +# every file within the 500-line cap. Provides the git-binary test seam +# (cleanup_wt_git), branch/worktree enumeration (enumerate_branches, +# parse_worktree_list), path normalization (normalize_wt_path), the +# current-worktree/branch protection set (compute_protected), and the main-freshness +# warning (check_main_freshness). The classification ladder, consolidation, deletion, +# and the CLI live in sibling files (cleanup_worktrees_lib.sh, +# cleanup_worktrees_actions_lib.sh, cleanup-worktrees.sh). +# +# Sourcing contract: this library defines functions only; it never runs work at +# source time, so the wrapper and the bats suites can source it without side effects. +# It MUST be sourced before cleanup_worktrees_lib.sh, whose classification functions +# call cleanup_wt_git, parse_worktree_list, compute_protected, and normalize_wt_path +# defined here. +# +# All git commands go through cleanup_wt_git so tests can stub the git binary via +# CLEANUP_WT_GIT_BIN. Git exit-code capture rule: the `git worktree list --porcelain` +# output consumed by parse_worktree_list is captured in the PARENT shell +# (`out=$(cmd) || rc=$?`, then iterated over `<<<"$out"`) so a non-zero exit is +# observed here rather than lost inside a process substitution. On a hard failure +# parse_worktree_list emits no records and returns git's non-zero exit code, and +# compute_protected propagates that failure rather than degrading to an empty +# (weakened) protected set. An empty porcelain output with exit 0 remains a valid +# empty list. enumerate_branches captures `git for-each-ref` in the parent shell +# (`out=$(...) || rc=$?`) BEFORE sorting, so a for-each-ref hard failure is observed +# even when the caller lacks pipefail; it returns git's exit code with no stdout rather +# than an empty branch list. compute_protected captures `git rev-parse --abbrev-ref +# HEAD` and `git rev-parse --show-toplevel`; a hard failure of either is fatal (returns +# git's exit code), never a weakened protection fallback. The detached-HEAD case +# (rev-parse succeeds printing HEAD) is unaffected. + +cleanup_wt_git() { + # Resolve the git binary honoring the CLEANUP_WT_GIT_BIN override seam, then + # execute it with the caller's arguments. + # + # When CLEANUP_WT_GIT_BIN is set to a non-empty value it must point to an + # existing executable; an empty or nonexistent value is treated as missing and + # falls back to `command -v git`. This mirrors the SHELL_QC__BIN seam in + # scripts/bash/shell_qc_lib.sh so the bats suites can stub git deterministically. + # + # Args: the git subcommand and its arguments. + # Returns git's exit code, or 127 when no git binary can be resolved. + local override=${CLEANUP_WT_GIT_BIN:-} + local git_bin="" + if [[ -n $override && -x $override ]]; then + git_bin=$override + else + git_bin=$(command -v git 2>/dev/null) || git_bin="" + fi + if [[ -z $git_bin ]]; then + printf 'cleanup-worktrees: no git binary resolved (CLEANUP_WT_GIT_BIN=%s)\n' "$override" >&2 + return 127 + fi + "$git_bin" "$@" +} + +enumerate_branches() { + # Enumerate local branches via plumbing, one "name sha" pair per line. + # + # Uses `git for-each-ref` rather than `git branch` so loose refs and packed-refs + # are read uniformly and the output carries no decoration markers (`*`, `+`) or + # column padding. Output is LC_ALL=C sorted for deterministic ordering and + # deterministic cross-branch cherry-pick order. + # + # The for-each-ref result is captured in the parent shell (`out=$(...) || rc=$?`) so + # a non-zero for-each-ref exit is observed here even when the caller lacks pipefail + # (the bats harness); piping straight into sort would attribute only sort's exit to + # rc and lose the for-each-ref failure. On a hard failure it prints a diagnostic to + # stderr, emits no stdout, and returns git's non-zero exit code. Returns git's exit + # code from for-each-ref (0 on success). + local rc=0 out + out=$(cleanup_wt_git for-each-ref \ + --format='%(refname:short) %(objectname)' refs/heads/) || rc=$? + if ((rc != 0)); then + printf 'cleanup-worktrees: git for-each-ref refs/heads/ failed (rc=%s)\n' "$rc" >&2 + return "$rc" + fi + # printf-pipe (not a herestring) so an empty ref list emits nothing rather than one + # empty line. + printf '%s' "$out" | LC_ALL=C sort +} + +parse_worktree_list() { + # Parse `git worktree list --porcelain` stanza-wise into pipe-delimited records. + # + # Emits one record per worktree: `path|head|branch-or-DETACHED|flags`. A new + # stanza begins at each `worktree ` line; a blank line closes the current stanza. + # The branch field carries the short branch name (refs/heads/ stripped) or the + # literal DETACHED when the stanza had a `detached` line. flags is a + # comma-separated set drawn from main,detached,bare,locked,prunable; the first + # stanza always carries the `main` flag (the main worktree is never a candidate). + # + # The porcelain listing is captured in the parent shell (`out=$(...) || rc=$?`) so + # a non-zero git exit is observed here rather than being lost inside a process + # substitution. Returns 0 on success (including an empty porcelain output with exit + # 0, which is a valid empty list); on a hard git failure it prints a diagnostic to + # stderr, emits no records, and returns git's non-zero exit code. + local rc=0 line out + local path="" head="" branch="" detached=0 bare=0 locked=0 prunable=0 + local first=1 have=0 + emit_record() { + # Flush the accumulated stanza as one record; no-op when none accumulated. + ((have == 0)) && return 0 + local -a flag_parts=() + ((first == 1)) && flag_parts+=("main") + ((detached == 1)) && flag_parts+=("detached") + ((bare == 1)) && flag_parts+=("bare") + ((locked == 1)) && flag_parts+=("locked") + ((prunable == 1)) && flag_parts+=("prunable") + local flags="" + local IFS=, + flags="${flag_parts[*]}" + local branch_field="DETACHED" + [[ -n $branch ]] && branch_field=$branch + printf '%s|%s|%s|%s\n' "$path" "$head" "$branch_field" "$flags" + first=0 + } + # Guarded parent-shell capture: a non-zero git exit is observed here (an unguarded + # `out=$(...)` would abort under the wrapper's set -euo pipefail). On a hard + # failure, return before emitting any record so the caller cannot mistake a git + # failure for an empty worktree list. + out=$(cleanup_wt_git worktree list --porcelain) || rc=$? + if ((rc != 0)); then + printf 'cleanup-worktrees: git worktree list --porcelain failed (rc=%s)\n' "$rc" >&2 + return "$rc" + fi + while IFS= read -r line || [[ -n $line ]]; do + case "$line" in + "worktree "*) + emit_record + path=${line#worktree } + head="" branch="" detached=0 bare=0 locked=0 prunable=0 + have=1 + ;; + "HEAD "*) head=${line#HEAD } ;; + "branch "*) branch=${line#branch refs/heads/} ;; + detached) detached=1 ;; + bare) bare=1 ;; + "locked"*) locked=1 ;; + "prunable"*) prunable=1 ;; + "") ;; # stanza separator; the next `worktree ` flushes the record + esac + done <<<"$out" + emit_record + return "$rc" +} + +normalize_wt_path() { + # Normalize a worktree path for comparison across Windows/WSL and git output. + # + # Converts backslashes to forward slashes, lowercases (case-insensitive match on + # Windows), and strips a single trailing slash. Echoes the normalized value; + # echoes nothing for an empty input. + # + # Args: $1 = path. + local p=${1:-} + [[ -z $p ]] && return 0 + p=${p//\\//} + p=${p,,} + p=${p%/} + printf '%s\n' "$p" +} + +compute_protected() { + # Compute the protected branch and protected worktree paths (PROTECTED_CURRENT). + # + # Dual exclusion, both checks required: + # 1. Current branch via `git rev-parse --abbrev-ref HEAD`. A `HEAD` result means + # detached: no branch name to protect (only the worktree path is protected). + # 2. Current worktree path via `git rev-parse --show-toplevel`, compared after + # slash/case normalization against each porcelain worktree path. + # The main worktree (first porcelain stanza) is always protected regardless of the + # above. Emits `protected-branch|` (omitted when detached) and one + # `protected-path|` line per protected worktree. + # + # Returns 0 on success. A parse_worktree_list hard failure propagates as its + # non-zero return; the caller must treat that as fatal, not as an empty (weakened) + # protected set. A hard failure of `git rev-parse --abbrev-ref HEAD` or + # `git rev-parse --show-toplevel` is likewise fatal (return git's exit code), never a + # weakened protection fallback; the detached-HEAD case (rev-parse succeeds printing + # HEAD) is unaffected and keeps its branch-name omission. + local rc=0 current_branch current_top norm_cur cbrc=0 ctrc=0 + current_branch=$(cleanup_wt_git rev-parse --abbrev-ref HEAD) || cbrc=$? + if ((cbrc != 0)); then + printf 'cleanup-worktrees: git rev-parse --abbrev-ref HEAD failed (rc=%s)\n' "$cbrc" >&2 + return "$cbrc" + fi + current_top=$(cleanup_wt_git rev-parse --show-toplevel) || ctrc=$? + if ((ctrc != 0)); then + printf 'cleanup-worktrees: git rev-parse --show-toplevel failed (rc=%s)\n' "$ctrc" >&2 + return "$ctrc" + fi + norm_cur=$(normalize_wt_path "$current_top") + if [[ -n $current_branch && $current_branch != HEAD ]]; then + printf 'protected-branch|%s\n' "$current_branch" + fi + local first=1 record path norm pout prc=0 + # Guarded parent-shell capture: a parse_worktree_list hard failure must abort here, + # not degrade to an empty protected set. Compare the current toplevel against each + # porcelain worktree path; the first stanza (main worktree) is always protected. + pout=$(parse_worktree_list) || prc=$? + if ((prc != 0)); then + return "$prc" + fi + while IFS= read -r record; do + [[ -z $record ]] && continue + path=${record%%|*} + norm=$(normalize_wt_path "$path") + if ((first == 1)); then + printf 'protected-path|%s\n' "$norm" + elif [[ -n $norm_cur && $norm == "$norm_cur" ]]; then + printf 'protected-path|%s\n' "$norm" + fi + first=0 + done <<<"$pout" + return "$rc" +} + +check_main_freshness() { + # Warn when local `main` diverges from `origin/main`; never block classification. + # + # Compares `git rev-parse main` with `git rev-parse origin/main`. On mismatch it + # emits the report line `WARN|main-divergence||`. A stale + # local `main` can only produce a false "not merged" (the safe direction), so this + # is advisory only and always returns 0. If either rev-parse fails (e.g. no + # origin/main configured), the check is skipped without error. + local local_sha origin_sha + local_sha=$(cleanup_wt_git rev-parse main 2>/dev/null) || return 0 + origin_sha=$(cleanup_wt_git rev-parse origin/main 2>/dev/null) || return 0 + if [[ -n $local_sha && -n $origin_sha && $local_sha != "$origin_sha" ]]; then + printf 'WARN|main-divergence|%s|%s\n' "$local_sha" "$origin_sha" + fi + return 0 +} diff --git a/scripts/bash/cleanup_worktrees_lib.sh b/scripts/bash/cleanup_worktrees_lib.sh new file mode 100644 index 000000000..f77550516 --- /dev/null +++ b/scripts/bash/cleanup_worktrees_lib.sh @@ -0,0 +1,479 @@ +#!/usr/bin/env bash +# cleanup_worktrees_lib.sh: sourceable function library for the cleanup-worktrees +# tool. Provides the full classification ladder (ancestry -> content-neutral -> +# cherry-equivalence -> rename-aware blob fallback -> unique-residual selection) and +# the report driver. The git-binary test seam (cleanup_wt_git), branch/worktree +# enumeration (enumerate_branches, parse_worktree_list), path normalization +# (normalize_wt_path), the current-worktree/branch protection set (compute_protected), +# and the main-freshness warning (check_main_freshness) now live in the sibling file +# cleanup_worktrees_enumerate_lib.sh, which MUST be sourced before this file. +# Consolidation, deletion, and the CLI live in cleanup_worktrees_actions_lib.sh and +# cleanup-worktrees.sh. Splitting the library keeps every file within the 500-line cap. +# +# Sourcing contract: this library defines functions only; it never runs work at +# source time, so the wrapper and the bats suites can source it without side effects. +# +# It depends on cleanup_worktrees_enumerate_lib.sh (source that first). All git +# commands go through cleanup_wt_git so tests can stub the git binary via +# CLEANUP_WT_GIT_BIN. +# +# Git exit-code capture rule: a git command with an EXPECTED non-zero exit in the +# ladder (merge-base --is-ancestor, diff --quiet) is captured at the function-body +# level with `|| rc=$?` and mapped to a verdict token. A git command whose output is +# ITERATED or whose exit code is authoritative (cherry, diff-tree, ls-tree, rev-list) +# is captured in the PARENT shell (`out=$(cmd) || rc=$?`, then iterated over +# `<<<"$out"`) so a non-zero exit is observed rather than lost inside a process +# substitution or a pipeline. Internal hard-error verdict tokens (echo-verdict +# contract, the callee returns 0; classify_branch maps each to the report state): +# CHERRY_ERROR - non-zero `git cherry` exit (classify_cherry_equivalent). +# DIFF_TREE_ERROR - non-zero `git diff-tree` exit while probing a `+` commit's diff +# for the empty-residual case (classify_cherry_equivalent). +# RESIDUAL_ERROR - non-zero exit of the name-status `git diff-tree` read or the +# D-rung `git ls-tree` probe (classify_residual_commit). +# MINUS_PRESENT - a `- ` cherry line was seen (partial-merge signal); read by +# classify_branch from the captured verdict instead of a second +# `git cherry` invocation whose exit code would be discarded. +# A hard failure of any enumeration/protection/cherry/diff-tree/ls-tree/rev-list read +# maps to the BRANCH||ANCESTRY_ERROR report state and a non-zero return. A hard +# git failure never resolves to a MERGED_* verdict. +# +# Report line contract (LC_ALL=C ordered, pipe-delimited, one record per line): +# BRANCH|| +# COMMIT|||||| +# WORKTREE||| +# WARN|main-divergence|| +# DIRTY|| +# ACTION||| (apply mode only; emitted by the actions lib) +# Branch states: NOT_MERGED | MERGED_CLEAN | MERGED_CONTENT_NEUTRAL | +# MERGED_EQUIVALENT | HAS_UNIQUE_RESIDUALS | PROTECTED_CURRENT; ANCESTRY_ERROR is a +# hard failure. Per-commit states: EQUIVALENT | CONTENT_ON_MAIN | EMPTY | UNIQUE | +# CONFLICT. + +classify_ancestry() { + # First ladder rung: is an ancestor of main? + # + # Wraps `git merge-base --is-ancestor main`, capturing the exit code with + # `|| rc=$?` so set -e does not abort on the expected non-zero. Echoes exactly one + # verdict token and always returns 0: + # exit 0 -> MERGED_CLEAN (delete-eligible; zero residual commits) + # exit 1 -> NOT_ANCESTOR (continue the ladder) + # exit > 1 -> ANCESTRY_ERROR (hard failure for the branch; never "not merged") + # + # Args: $1 = branch tip (name or sha). + local tip="$1" rc=0 + cleanup_wt_git merge-base --is-ancestor "$tip" main >/dev/null 2>&1 || rc=$? + if ((rc == 0)); then + printf 'MERGED_CLEAN\n' + elif ((rc == 1)); then + printf 'NOT_ANCESTOR\n' + else + printf 'ANCESTRY_ERROR\n' + fi + return 0 +} + +classify_content_neutral() { + # Second ladder rung: does the branch add no net content versus main? + # + # Runs `git diff --quiet main...` (three-dot: merge-base(main,branch) -> + # branch tip) with `|| rc=$?`. This short-circuit runs BEFORE any per-commit + # `git cherry` analysis and catches revert-pairs (a commit and its later revert + # on the same branch net to nothing). Echoes exactly one verdict token and always + # returns 0: + # exit 0 -> MERGED_CONTENT_NEUTRAL (delete-eligible) + # exit 1 -> NOT_NEUTRAL (continue the ladder) + # exit > 1 -> CONTENT_NEUTRAL_ERROR (hard failure for the branch) + # + # Args: $1 = branch name. + local branch="$1" rc=0 + cleanup_wt_git diff --quiet "main...$branch" >/dev/null 2>&1 || rc=$? + if ((rc == 0)); then + printf 'MERGED_CONTENT_NEUTRAL\n' + elif ((rc == 1)); then + printf 'NOT_NEUTRAL\n' + else + printf 'CONTENT_NEUTRAL_ERROR\n' + fi + return 0 +} + +classify_cherry_equivalent() { + # Third ladder rung: patch-id equivalence via `git cherry main `. + # + # `git cherry` emits `- ` when an equivalent patch already exists on main and + # `+ ` when none does. A `+` commit whose diff is empty (verified with + # `git diff-tree --no-commit-id -r ` producing no output) is also counted as + # equivalent (droppable empty commit). When every residual is equivalent the branch + # is content-equivalent-merged. + # + # Output contract (one token per line; always returns 0): + # - if all residuals are equivalent: a single line `MERGED_EQUIVALENT` + # - otherwise: a `MINUS_PRESENT` line first (only when any `- ` cherry line + # was seen), then one `RESIDUAL ` line per remaining `+` commit for the + # blob-level tier (classify_residual_commit). classify_branch reads MINUS_PRESENT + # from this captured verdict instead of re-invoking `git cherry`. + # - on a hard `git cherry` failure (non-zero exit): a single line `CHERRY_ERROR`. + # `git cherry` has no expected non-zero in this ladder, so any non-zero exit is + # a hard failure; the caller (classify_branch) maps CHERRY_ERROR to the + # ANCESTRY_ERROR report state and never to MERGED_EQUIVALENT. + # - on a hard `git diff-tree` failure while probing a `+` commit's diff (non-zero + # exit): a single line `DIFF_TREE_ERROR`. diff-tree has no expected non-zero + # here, so any non-zero exit is a hard failure; the caller maps DIFF_TREE_ERROR + # to ANCESTRY_ERROR and never to MERGED_EQUIVALENT. An empty diff-tree output + # with exit 0 remains the legitimate droppable empty-commit case. + # + # Args: $1 = branch name. + local branch="$1" rc=0 line marker sha out minus_seen=0 + local -a residuals=() + # Guarded parent-shell capture: a non-zero `git cherry` exit is observed here and + # reported as CHERRY_ERROR before any residual processing, so an empty/failed cherry + # result cannot be mistaken for "all residuals equivalent". + out=$(cleanup_wt_git cherry main "$branch") || rc=$? + if ((rc != 0)); then + printf 'CHERRY_ERROR\n' + return 0 + fi + while IFS= read -r line || [[ -n $line ]]; do + [[ -z $line ]] && continue + marker=${line%% *} + sha=${line#* } + # `-` lines are patch-id equivalent: nothing to carry forward, but record that a + # partial-merge signal exists so classify_branch need not re-invoke `git cherry`. + if [[ $marker == "-" ]]; then + minus_seen=1 + continue + fi + if [[ $marker == "+" ]]; then + local dt="" dtrc=0 + # Guarded parent-shell capture: a non-zero diff-tree exit is a hard failure + # reported as DIFF_TREE_ERROR, never mistaken for an empty (droppable) diff. + dt=$(cleanup_wt_git diff-tree --no-commit-id -r "$sha") || dtrc=$? + if ((dtrc != 0)); then + printf 'DIFF_TREE_ERROR\n' + return 0 + fi + # An empty-diff residual commit is droppable; treat as equivalent. + [[ -z $dt ]] && continue + residuals+=("$sha") + fi + done <<<"$out" + if ((${#residuals[@]} == 0)); then + printf 'MERGED_EQUIVALENT\n' + else + ((minus_seen == 1)) && printf 'MINUS_PRESENT\n' + for sha in "${residuals[@]}"; do + printf 'RESIDUAL %s\n' "$sha" + done + fi + return 0 +} + +_blob_equal() { + # Return 0 iff : and : resolve to the same blob OID. + # + # A rev-parse failure for either side (e.g. the path is absent on that ref) is + # treated as "not equal" (return 1) so an added/removed path is never mistaken for + # equivalent content. + # + # Args: $1 = ref-a, $2 = ref-b, $3 = path. + local ref_a="$1" ref_b="$2" path="$3" oid_a oid_b + oid_a=$(cleanup_wt_git rev-parse "$ref_a:$path" 2>/dev/null) || return 1 + oid_b=$(cleanup_wt_git rev-parse "$ref_b:$path" 2>/dev/null) || return 1 + [[ -n $oid_a && $oid_a == "$oid_b" ]] +} + +classify_residual_commit() { + # Fourth ladder rung: rename-aware blob-OID comparison for one `+` commit. + # + # Enumerates touched paths via + # git diff-tree --no-commit-id --name-status -r -M + # and decides per path whether the branch's content already exists on main: + # A / M : compare blob OIDs of : vs main:; differ or absent + # on main -> unique. + # D : probe `git ls-tree main -- ` (guarded capture). Non-empty output + # means the path is still present on main -> the branch's deletion is + # unique work; empty output (exit 0) means the path is also absent on main + # -> droppable. A non-zero ls-tree exit is a hard failure -> RESIDUAL_ERROR. + # Rnnn : compare blob OIDs at the NEW path (rename target). + # All touched paths equivalent -> CONTENT_ON_MAIN; any unique path -> UNIQUE with + # the comma-separated unique path list. + # + # Output contract (single line; always returns 0): + # CONTENT_ON_MAIN + # UNIQUE| + # RESIDUAL_ERROR (hard git failure of the name-status diff-tree read or the + # D-rung ls-tree probe; never resolved to CONTENT_ON_MAIN/UNIQUE) + # + # Args: $1 = branch name, $2 = commit sha. + local branch="$1" sha="$2" status p1 p2 relpath out dtrc=0 + local -a unique_paths=() + # Guarded parent-shell capture: a non-zero name-status diff-tree exit is a hard + # failure reported as RESIDUAL_ERROR before any path processing, never resolved to + # CONTENT_ON_MAIN or UNIQUE. + out=$(cleanup_wt_git diff-tree --no-commit-id --name-status -r -M "$sha") || dtrc=$? + if ((dtrc != 0)); then + printf 'RESIDUAL_ERROR\n' + return 0 + fi + while IFS=$'\t' read -r status p1 p2 || [[ -n $status ]]; do + [[ -z $status ]] && continue + case "$status" in + A | M) + relpath="$p1" + _blob_equal "$branch" main "$relpath" || unique_paths+=("$relpath") + ;; + D) + relpath="$p1" + # Guarded ls-tree probe: a hard git failure (RESIDUAL_ERROR) is distinct from a + # path legitimately absent on main (droppable). rev-parse's exit code cannot + # make this distinction, so use ls-tree's stdout: non-empty -> present on main + # (the deletion is unique work); empty with exit 0 -> absent (droppable). + local lsout="" lsrc=0 + lsout=$(cleanup_wt_git ls-tree main -- "$relpath") || lsrc=$? + if ((lsrc != 0)); then + printf 'RESIDUAL_ERROR\n' + return 0 + fi + [[ -n $lsout ]] && unique_paths+=("$relpath") + ;; + R*) + relpath="$p2" + _blob_equal "$branch" main "$relpath" || unique_paths+=("$relpath") + ;; + esac + done <<<"$out" + if ((${#unique_paths[@]} == 0)); then + printf 'CONTENT_ON_MAIN\n' + else + local IFS=, + printf 'UNIQUE|%s\n' "${unique_paths[*]}" + fi + return 0 +} + +select_cherry_pick_candidates() { + # Emit one COMMIT record per UNIQUE residual commit, oldest-first. + # + # The non-equivalent `+` residual set comes from classify_cherry_equivalent; the + # oldest-first order and per-commit author/author-date come from + # git rev-list --reverse --no-merges --format='%H|%an|%aI' main.. + # (`--reverse` = application order for cherry-picking; `--no-merges` excludes merge + # commits; the `commit ` header lines from --format are dropped). Emits, per + # UNIQUE residual: COMMIT|||UNIQUE|||. + # + # Args: $1 = branch name. + local branch="$1" line sha author date verdict paths restline rc=0 + local ceout cerc=0 rlout + local -A is_residual=() + # Defensive re-check of the cherry verdict: a CHERRY_ERROR (or a non-zero return) + # must abort with a non-zero status rather than proceed with an empty residual map. + ceout=$(classify_cherry_equivalent "$branch") || cerc=$? + if ((cerc != 0)) || [[ $ceout == "CHERRY_ERROR" || $ceout == "DIFF_TREE_ERROR" ]]; then + return 2 + fi + while IFS= read -r line; do + [[ $line == RESIDUAL\ * ]] || continue + is_residual[${line#RESIDUAL }]=1 + done <<<"$ceout" + # Guarded parent-shell capture: a rev-list hard failure returns git's exit code + # with no COMMIT line emitted, so a candidate is never silently fabricated or + # dropped as a success. + rlout=$(cleanup_wt_git rev-list --reverse --no-merges --format='%H|%an|%aI' "main..$branch") || rc=$? + if ((rc != 0)); then + printf 'cleanup-worktrees: git rev-list failed for %s (rc=%s)\n' "$branch" "$rc" >&2 + return "$rc" + fi + while IFS= read -r line || [[ -n $line ]]; do + [[ $line == commit\ * ]] && continue + [[ -z $line ]] && continue + sha=${line%%|*} + restline=${line#*|} + author=${restline%%|*} + date=${restline#*|} + [[ -n ${is_residual[$sha]:-} ]] || continue + verdict=$(classify_residual_commit "$branch" "$sha") + if [[ $verdict == "RESIDUAL_ERROR" ]]; then + # A residual hard error must abort candidate selection with a non-zero status + # rather than silently drop or fabricate a COMMIT record. + return 2 + fi + if [[ $verdict == UNIQUE\|* ]]; then + paths=${verdict#UNIQUE|} + printf 'COMMIT|%s|%s|UNIQUE|%s|%s|%s\n' "$branch" "$sha" "$paths" "$author" "$date" + fi + done <<<"$rlout" + return "$rc" +} + +classify_branch() { + # Orchestrate the full classification ladder for one branch and emit its pinned + # report lines (BRANCH first, then COMMIT records for unique residuals oldest-first). + # No commit-message text is ever consulted. Ladder order (spec): + # 1. PROTECTED_CURRENT exclusion (branch-name OR worktree-path match; main + # worktree always protected). + # 2. ancestry -> MERGED_CLEAN, or ANCESTRY_ERROR (hard fail). + # 3. content-neutral short-circuit -> MERGED_CONTENT_NEUTRAL. + # 4. cherry patch-id equivalence -> MERGED_EQUIVALENT when all residuals equiv. + # 5. rename-aware blob fallback: residuals all CONTENT_ON_MAIN -> MERGED_EQUIVALENT. + # 6. remaining unique residuals -> HAS_UNIQUE_RESIDUALS when the branch was + # partially incorporated on main (a cherry `-` line or a CONTENT_ON_MAIN + # residual), else NOT_MERGED (purely unmerged code). + # + # Args: $1 = branch name. Returns 0 normally; 2 on any hard error surfaced by the + # enumeration/protection reads (worktree-list, protected-set), the cherry rung + # (CHERRY_ERROR), the empty-residual diff-tree probe (DIFF_TREE_ERROR), the + # name-status/ls-tree residual reads (RESIDUAL_ERROR), or rev-list candidate + # selection. A hard git failure always maps to the ANCESTRY_ERROR report state and a + # non-zero return, never a MERGED verdict. + local name="$1" + local -A prot_branch=() prot_path=() + local pline cpout cprc=0 + # Guarded parent-shell capture of the protected set: a compute_protected hard + # failure (a git worktree-list failure underneath) must map to ANCESTRY_ERROR, not + # a weakened/empty protected set that could let a real branch reach a MERGED verdict. + cpout=$(compute_protected) || cprc=$? + if ((cprc != 0)); then + printf 'BRANCH|%s|ANCESTRY_ERROR\n' "$name" + return 2 + fi + while IFS= read -r pline; do + case "$pline" in + protected-branch\|*) prot_branch[${pline#protected-branch|}]=1 ;; + protected-path\|*) prot_path[${pline#protected-path|}]=1 ;; + esac + done <<<"$cpout" + # Locate this branch's worktree path (normalized), if any, from a guarded capture + # of the worktree list; a hard failure here likewise maps to ANCESTRY_ERROR. + local wt_norm="" record wpath wbranch wlout wlrc=0 + wlout=$(parse_worktree_list) || wlrc=$? + if ((wlrc != 0)); then + printf 'BRANCH|%s|ANCESTRY_ERROR\n' "$name" + return 2 + fi + while IFS= read -r record; do + [[ -z $record ]] && continue + IFS='|' read -r wpath _ wbranch _ <<<"$record" + if [[ $wbranch == "$name" ]]; then + wt_norm=$(normalize_wt_path "$wpath") + break + fi + done <<<"$wlout" + if [[ -n ${prot_branch[$name]:-} ]] || { [[ -n $wt_norm && -n ${prot_path[$wt_norm]:-} ]]; }; then + printf 'BRANCH|%s|PROTECTED_CURRENT\n' "$name" + return 0 + fi + local v + v=$(classify_ancestry "$name") + case "$v" in + MERGED_CLEAN) + printf 'BRANCH|%s|MERGED_CLEAN\n' "$name" + return 0 + ;; + ANCESTRY_ERROR) + printf 'BRANCH|%s|ANCESTRY_ERROR\n' "$name" + return 2 + ;; + esac + v=$(classify_content_neutral "$name") + case "$v" in + MERGED_CONTENT_NEUTRAL) + printf 'BRANCH|%s|MERGED_CONTENT_NEUTRAL\n' "$name" + return 0 + ;; + CONTENT_NEUTRAL_ERROR) + printf 'BRANCH|%s|ANCESTRY_ERROR\n' "$name" + return 2 + ;; + esac + local ce + ce=$(classify_cherry_equivalent "$name") + if [[ $ce == "CHERRY_ERROR" || $ce == "DIFF_TREE_ERROR" ]]; then + # Hard `git cherry` or `git diff-tree` failure: same mapping as + # CONTENT_NEUTRAL_ERROR (ANCESTRY_ERROR report state, hard-fail return 2). + printf 'BRANCH|%s|ANCESTRY_ERROR\n' "$name" + return 2 + fi + if [[ $ce == "MERGED_EQUIVALENT" ]]; then + printf 'BRANCH|%s|MERGED_EQUIVALENT\n' "$name" + return 0 + fi + # Residual `+` commits exist; resolve each via the blob tier. + local sha verdict unique_count=0 content_count=0 + while IFS= read -r pline; do + [[ $pline == RESIDUAL\ * ]] || continue + sha=${pline#RESIDUAL } + verdict=$(classify_residual_commit "$name" "$sha") + if [[ $verdict == "RESIDUAL_ERROR" ]]; then + # Hard failure of the name-status diff-tree read or the D-rung ls-tree probe: + # map to ANCESTRY_ERROR before any state line is emitted. + printf 'BRANCH|%s|ANCESTRY_ERROR\n' "$name" + return 2 + fi + if [[ $verdict == UNIQUE\|* ]]; then + unique_count=$((unique_count + 1)) + elif [[ $verdict == "CONTENT_ON_MAIN" ]]; then + content_count=$((content_count + 1)) + fi + done < <(printf '%s\n' "$ce") + if ((unique_count == 0)); then + # Every residual was content-on-main; the branch is content-equivalent-merged. + printf 'BRANCH|%s|MERGED_EQUIVALENT\n' "$name" + return 0 + fi + # Partial-merge signal: was any part of this branch already incorporated on main? + # Derived from the already-captured cherry verdict (MINUS_PRESENT token) instead of a + # second `git cherry` invocation whose exit code would be discarded in a pipeline. + local minus_present=0 + [[ $ce == *MINUS_PRESENT* ]] && minus_present=1 + local state="NOT_MERGED" + if ((minus_present == 1)) || ((content_count > 0)); then + state="HAS_UNIQUE_RESIDUALS" + fi + printf 'BRANCH|%s|%s\n' "$name" "$state" + if [[ $state == "HAS_UNIQUE_RESIDUALS" ]]; then + # Propagate a rev-list/cherry hard failure: the BRANCH line already names a + # non-delete-eligible state, and the non-zero return signals the hard error. + local scrc=0 + select_cherry_pick_candidates "$name" || scrc=$? + if ((scrc != 0)); then + return 2 + fi + fi + return 0 +} + +run_report() { + # Report-mode driver: emit the deterministic report with no mutation of any kind. + # Emission order: WARN (freshness) first, then WORKTREE registrations, then the + # per-branch BRANCH/COMMIT lines with branches taken in enumerate_branches' + # LC_ALL=C order. Returns the maximum classify_branch return code (non-zero when + # any branch reported ANCESTRY_ERROR). A worktree-list OR enumerate-branches hard + # failure aborts the report before any line is emitted and returns git's non-zero + # exit code, so a git failure never resolves to a partial, misleading report. + local rc=0 crc name record wpath wbranch wflags wlout wlrc=0 ebout ebrc=0 + # Guarded parent-shell captures up front, before any output: a hard failure of either + # read aborts the report before any WARN/WORKTREE/BRANCH line. + wlout=$(parse_worktree_list) || wlrc=$? + if ((wlrc != 0)); then + return "$wlrc" + fi + ebout=$(enumerate_branches) || ebrc=$? + if ((ebrc != 0)); then + return "$ebrc" + fi + check_main_freshness + while IFS= read -r record; do + [[ -z $record ]] && continue + IFS='|' read -r wpath _ wbranch wflags <<<"$record" + printf 'WORKTREE|%s|%s|%s\n' "$wpath" "$wbranch" "$wflags" + done <<<"$wlout" + while read -r name _; do + [[ -z $name ]] && continue + crc=0 + classify_branch "$name" || crc=$? + if ((crc > rc)); then + rc=$crc + fi + done <<<"$ebout" + return "$rc" +} diff --git a/scripts/bash/coverage_demo.sh b/scripts/bash/coverage_demo.sh new file mode 100644 index 000000000..6e6357935 --- /dev/null +++ b/scripts/bash/coverage_demo.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/coverage_lib.sh" + +greet_user "${1:-Demo}" diff --git a/scripts/bash/coverage_lib.sh b/scripts/bash/coverage_lib.sh new file mode 100644 index 000000000..4b7d8e941 --- /dev/null +++ b/scripts/bash/coverage_lib.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +greet_user() { + local name="${1:-Demo}" + echo "Hello, ${name}!" +} diff --git a/scripts/bash/shell-qc.sh b/scripts/bash/shell-qc.sh new file mode 100644 index 000000000..0a3753f0f --- /dev/null +++ b/scripts/bash/shell-qc.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# shell-qc.sh: native bash quality-control wrapper for repository shell scripts. +# Subcommands: check | format | test [--coverage] | --help. No Python, no Poetry. +# The heavy lifting lives in scripts/bash/shell_qc_lib.sh so the functions are unit +# testable from bats by sourcing the library directly. +set -euo pipefail + +# Resolve this script's own directory so the library sources regardless of cwd. +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# The library path is resolved at runtime from SCRIPT_DIR, so shellcheck cannot load it +# as a static input without -x; SC1091 is the expected, benign result and is suppressed. +# shellcheck source=scripts/bash/shell_qc_lib.sh +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/shell_qc_lib.sh" + +usage() { + # Print the wrapper usage/help text. + cat <<'EOF' +Usage: shell-qc.sh [options] + +Commands: + check Run shfmt -d (diff) and shellcheck over discovered scripts. + format Rewrite discovered scripts in place with shfmt -w. + test [--coverage] Run bats over tests/shell and tests/bash. With --coverage, + run under kcov, emit a Cobertura cov.xml, and print a + "Bash coverage (lines): NN.N%" summary. + --help, -h, help Print this help and exit 0. + +Discovery searches tools/ and scripts/; a file qualifies by a .sh suffix or a +bash/sh shebang (including env forms); the dirs .venv .git node_modules dist +build are excluded. + +Environment overrides: + SHELL_QC__BIN Path to shfmt/shellcheck/bats/kcov; an empty or + nonexistent value is treated as missing. + SHELL_QC_KCOV_OUT_DIR Coverage output directory (default artifacts/pester/kcov). +EOF +} + +main() { + # Dispatch on the first argument. Unknown or missing subcommands, extra + # positionals on check/format, and unknown test flags print usage to stderr and + # exit 2 (argparse usage-error parity). Subcommand return codes are captured so + # an intermediate failure is never masked before the final exit. + local exit_code=0 + local command=${1:-} + case "$command" in + check) + if (($# > 1)); then + usage >&2 + return 2 + fi + run_check || exit_code=$? + ;; + format) + if (($# > 1)); then + usage >&2 + return 2 + fi + run_format || exit_code=$? + ;; + test) + shift + local coverage=0 + # Only --coverage is accepted; any other token is a usage error. + while (($# > 0)); do + case "$1" in + --coverage) + coverage=1 + ;; + *) + usage >&2 + return 2 + ;; + esac + shift + done + if ((coverage == 1)); then + run_test_coverage || exit_code=$? + else + run_test || exit_code=$? + fi + ;; + --help | -h | help) + usage + return 0 + ;; + *) + usage >&2 + return 2 + ;; + esac + return "$exit_code" +} + +# Guard so the file can be sourced without executing main. main's return code is +# captured and re-exited explicitly as the final statement, so intermediate +# `|| rc=$?` captures inside the library cannot mask a real failure. +if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then + rc=0 + main "$@" || rc=$? + exit "$rc" +fi diff --git a/scripts/bash/shell_qc_lib.sh b/scripts/bash/shell_qc_lib.sh new file mode 100644 index 000000000..6d41fdd2e --- /dev/null +++ b/scripts/bash/shell_qc_lib.sh @@ -0,0 +1,379 @@ +#!/usr/bin/env bash +# shell_qc_lib.sh: sourceable function library for the native bash shell-qc +# toolchain. Provides script discovery, shebang parsing, tool resolution (with +# the SHELL_QC__BIN test seam), the missing-tool message block, the +# check/format/test/coverage implementations, and Cobertura line-rate parsing. +# Contains no Python and no Poetry. Intended to be sourced by scripts/bash/shell-qc.sh +# and by bats tests. All external tools (shfmt, shellcheck, bats, kcov) legitimately +# return non-zero, so every invocation is captured with `|| rc=$?` under set -e. + +# EXCLUDED_DIRS are pruned during discovery traversal at any depth. +# SEARCH_DIRS and TEST_DIR_CANDIDATES mirror the removed Python module's constants. + +extract_shebang_command() { + # Extract the interpreter command from a file's shebang line. + # + # Reads only the first line (parity with reading one line in Python), lowercases + # it (so `#!/usr/bin/env BASH` qualifies), strips the `#!` and surrounding + # whitespace, word-splits the payload, and takes the basename of the first token. + # When that token is `env`, it is dropped along with any leading `-`-prefixed + # option tokens (covering `env -S bash` and `env -flag` forms) and the basename of + # the next token is used. Shebang lines never legitimately contain quotes, so plain + # word-splitting is acceptable parity for Python's shlex.split. + # + # Args: $1 = path to inspect. + # Echoes the resolved command name on success; prints nothing and returns 1 when + # the file is unreadable, has no shebang, or has an empty payload. + local file="$1" + local first_line payload + # Unreadable file (open failure) is treated as not a shell script, no error. + IFS= read -r first_line <"$file" 2>/dev/null || return 1 + first_line=${first_line,,} + [[ $first_line == '#!'* ]] || return 1 + payload=${first_line:2} + # Trim leading and trailing whitespace, mirroring Python's str.strip(). + payload=${payload#"${payload%%[![:space:]]*}"} + payload=${payload%"${payload##*[![:space:]]}"} + [[ -n $payload ]] || return 1 + local -a parts + read -ra parts <<<"$payload" + ((${#parts[@]})) || return 1 + local cmd=${parts[0]##*/} + if [[ $cmd == env ]]; then + local i=1 + # Skip env's own option flags before the interpreter token. + while ((i < ${#parts[@]})) && [[ ${parts[$i]} == -* ]]; do + i=$((i + 1)) + done + ((i < ${#parts[@]})) || return 1 + cmd=${parts[$i]##*/} + fi + printf '%s\n' "$cmd" +} + +is_shell_script() { + # Return success when a path looks like a shell script. + # + # A path qualifies when its basename lowercased ends in `.sh` (parity with + # Path.suffix.lower() == ".sh") or when its shebang resolves to bash or sh. + # + # Args: $1 = path to inspect. Returns 0 when shell, 1 otherwise. + local file="$1" + local base=${file##*/} + local lower=${base,,} + if [[ $lower == *.sh ]]; then + return 0 + fi + local cmd + if cmd=$(extract_shebang_command "$file"); then + [[ $cmd == bash || $cmd == sh ]] + return + fi + return 1 +} + +discover_shell_scripts() { + # Discover shell scripts under tools/, scripts/, and .claude/lib/bash/ relative to + # the current dir. + # + # Missing roots are silently skipped. Traversal prunes .venv, .git, node_modules, + # dist, and build at any depth. Output is de-duplicated and sorted with LC_ALL=C + # for deterministic codepoint ordering, matching the removed module's sorted set. + local -a roots=() + local root + # Collect only the search roots that exist, mirroring Path.exists() skipping. + for root in tools scripts .claude/lib/bash; do + if [[ -d $root ]]; then + roots+=("$root") + fi + done + ((${#roots[@]})) || return 0 + local f + # Walk each root, pruning excluded directories, and emit qualifying files. + # NUL-delimited output makes the pipeline robust to unusual path characters. + while IFS= read -r -d '' f; do + if is_shell_script "$f"; then + printf '%s\n' "$f" + fi + done < <(find "${roots[@]}" \ + -type d \( -name .venv -o -name .git -o -name node_modules \ + -o -name dist -o -name build \) -prune \ + -o -type f -print0) | LC_ALL=C sort -u +} + +find_bats_test_dirs() { + # Echo existing bats test directories, tests/shell then tests/bash, in that order. + # Emits one path per line; emits nothing when neither directory exists. + local -a dirs=() + local candidate + # Preserve the documented precedence order of the two candidate directories. + for candidate in "tests/shell" "tests/bash"; do + if [[ -d $candidate ]]; then + dirs+=("$candidate") + fi + done + ((${#dirs[@]})) || return 0 + local d + for d in "${dirs[@]}"; do + printf '%s\n' "$d" + done +} + +resolve_tool() { + # Resolve an external tool path, honoring the SHELL_QC__BIN override seam. + # + # When SHELL_QC__BIN is set to a non-empty value, that value must point to an + # existing executable; an empty or nonexistent override is treated as missing. + # Without an override, the tool is resolved from PATH via command -v. + # + # Args: $1 = tool name (lowercase: shfmt|shellcheck|bats|kcov). + # Echoes the resolved path on success; prints nothing and returns 1 when missing. + local tool="$1" + local var="SHELL_QC_${tool^^}_BIN" + local override=${!var:-} + if [[ -n $override ]]; then + # An explicit override must be a runnable executable to count as present. + if [[ -x $override ]]; then + printf '%s\n' "$override" + return 0 + fi + return 1 + fi + local resolved + if resolved=$(command -v "$tool" 2>/dev/null); then + printf '%s\n' "$resolved" + return 0 + fi + return 1 +} + +print_missing_tool_block() { + # Print the exact five-line missing-tool block. The package name defaults to the + # tool name. This block is a byte-identical contract with the removed module. + # + # Args: $1 = tool name; $2 = optional package name (defaults to $1). + local tool="$1" + local package="${2:-$1}" + printf 'Missing required tool: %s\n' "$tool" + printf 'Devcontainer install (apt-get): apt-get update && apt-get install -y %s\n' "$package" + printf 'macOS (Homebrew): brew install %s\n' "$package" + printf 'Ubuntu/Debian: sudo apt-get update && sudo apt-get install -y %s\n' "$package" + printf 'On Windows, use WSL for best results.\n' +} + +run_check() { + # Run shfmt in diff mode and shellcheck across discovered scripts. + # + # No scripts prints the skip message and returns 0. Both tools are preflighted in + # order (shfmt then shellcheck); a missing tool prints the block and returns 127. + # shfmt runs once over the full file list; shellcheck runs once per file so + # findings stay attributable. The return value is the maximum observed exit code. + local -a files=() + mapfile -t files < <(discover_shell_scripts) + if ((${#files[@]} == 0)); then + printf 'No shell scripts found; skipping.\n' + return 0 + fi + local shfmt_bin shellcheck_bin + if ! shfmt_bin=$(resolve_tool shfmt); then + print_missing_tool_block shfmt + return 127 + fi + if ! shellcheck_bin=$(resolve_tool shellcheck); then + print_missing_tool_block shellcheck + return 127 + fi + local exit_code=0 rc=0 + # shfmt legitimately returns non-zero when a file differs; capture, do not abort. + "$shfmt_bin" -d "${files[@]}" || rc=$? + if ((rc > exit_code)); then + exit_code=$rc + fi + local file + # Lint each discovered file independently to keep failures localized. + for file in "${files[@]}"; do + rc=0 + "$shellcheck_bin" "$file" || rc=$? + if ((rc > exit_code)); then + exit_code=$rc + fi + done + return "$exit_code" +} + +run_format() { + # Run shfmt in write mode across discovered scripts. + # + # No scripts prints the skip message and returns 0. A missing shfmt prints the + # block and returns 127. Otherwise shfmt -w runs over the full list and its exit + # code is returned. + local -a files=() + mapfile -t files < <(discover_shell_scripts) + if ((${#files[@]} == 0)); then + printf 'No shell scripts found; skipping.\n' + return 0 + fi + local shfmt_bin + if ! shfmt_bin=$(resolve_tool shfmt); then + print_missing_tool_block shfmt + return 127 + fi + local rc=0 + "$shfmt_bin" -w "${files[@]}" || rc=$? + return "$rc" +} + +run_test() { + # Run bats against tests/shell and tests/bash without coverage. + # + # No test directory prints the exact skip marker consumed by fix_all.py and + # returns 0. A missing bats prints the exact non-coverage skip marker and returns + # 0. Otherwise bats runs once per directory; all directories run even if one + # fails, and the maximum exit code is returned. + local -a test_dirs=() + mapfile -t test_dirs < <(find_bats_test_dirs) + if ((${#test_dirs[@]} == 0)); then + printf 'No shell test directories found; skipping.\n' + return 0 + fi + local bats_bin + if ! bats_bin=$(resolve_tool bats); then + printf 'bats not installed; skipping shell tests.\n' + return 0 + fi + local exit_code=0 rc=0 test_dir + # Run every directory even on failure so all suites report, matching prior behavior. + for test_dir in "${test_dirs[@]}"; do + rc=0 + "$bats_bin" "$test_dir" || rc=$? + if ((rc > exit_code)); then + exit_code=$rc + fi + done + return "$exit_code" +} + +extract_cobertura_line_rate() { + # Echo the first line-rate attribute value from a Cobertura cov.xml. + # + # Matches line-rate="0.75" or line-rate='0.75'. Prints nothing and returns 1 when + # the file is missing or no attribute is found, so callers can treat absence as a + # silent no-op (parity with the removed module returning None). + # + # Args: $1 = path to cov.xml. + local cov_xml="$1" + [[ -f $cov_xml ]] || return 1 + local match + match=$(grep -oE "line-rate=[\"'][0-9.]+[\"']" "$cov_xml" 2>/dev/null | head -n1) || true + [[ -n $match ]] || return 1 + # Strip the attribute name and the surrounding single-or-double quotes. + local value=${match#line-rate=} + value=${value#?} + value=${value%?} + [[ -n $value ]] || return 1 + printf '%s\n' "$value" +} + +print_coverage_summary() { + # Print "Bash coverage (lines): NN.N%" using the cov.xml line-rate. + # + # Prints nothing when cov.xml is missing or unparseable, matching the removed + # module's no-op behavior. The percentage carries exactly one decimal place. + # + # Args: $1 = path to cov.xml. + local cov_xml="$1" + local rate + if ! rate=$(extract_cobertura_line_rate "$cov_xml"); then + return 0 + fi + local percent + percent=$(awk -v r="$rate" 'BEGIN { printf "%.1f", r * 100 }') + printf 'Bash coverage (lines): %s%%\n' "$percent" +} + +run_test_coverage() { + # Run bats under kcov, merge the reports, and print the coverage summary. + # + # No test directory prints the exact skip marker and returns 0. A missing bats + # prints the coverage-mode message and returns 127. A missing kcov prints its + # message plus the missing-tool block and returns 127. The output directory + # (SHELL_QC_KCOV_OUT_DIR, default artifacts/pester/kcov, resolved against the repo + # root when relative) is deleted and recreated. Each directory runs under kcov with + # a stable run dir, stopping at the first failure; successful runs are merged into a + # single cov.xml. The transient .kcov_runs directory is always removed. + local -a test_dirs=() + mapfile -t test_dirs < <(find_bats_test_dirs) + if ((${#test_dirs[@]} == 0)); then + printf 'No shell test directories found; skipping.\n' + return 0 + fi + local bats_bin + if ! bats_bin=$(resolve_tool bats); then + printf 'bats not installed; cannot run shell tests with coverage.\n' + return 127 + fi + local kcov_bin + if ! kcov_bin=$(resolve_tool kcov); then + printf 'kcov not installed; cannot run shell tests with coverage.\n' + print_missing_tool_block kcov + return 127 + fi + local repo_root + repo_root=$(pwd) + local out_dir=${SHELL_QC_KCOV_OUT_DIR:-artifacts/pester/kcov} + # Resolve a relative output directory against the repo root for stable placement. + if [[ $out_dir != /* ]]; then + out_dir="$repo_root/$out_dir" + fi + # Start from a clean slate so stale coverage is never merged or displayed. + rm -rf "$out_dir" + mkdir -p "$out_dir" + local runs_dir="$out_dir/.kcov_runs" + mkdir -p "$runs_dir" + # Scope coverage to repo scripts/tools and the Claude bash library; exclude the test + # sources themselves. + local include_pattern="$repo_root/tools,$repo_root/scripts,$repo_root/.claude/lib/bash" + local exclude_pattern="$repo_root/tests" + local exit_code=0 rc=0 test_dir + local -a run_dirs=() + # Run directories in order, stopping on the first failure to keep logs readable. + for test_dir in "${test_dirs[@]}"; do + local run_dir="$runs_dir/${test_dir##*/}" + run_dirs+=("$run_dir") + rc=0 + # Do not pass --cobertura-only here: that flag suppresses the per-run coverage + # database that `kcov --merge` consumes, producing an empty merged report. kcov + # still emits Cobertura output in the merged directory without it. + "$kcov_bin" \ + "--include-pattern=$include_pattern" \ + "--exclude-pattern=$exclude_pattern" \ + "$run_dir" "$bats_bin" "$test_dir" || rc=$? + if ((rc > exit_code)); then + exit_code=$rc + fi + if ((exit_code != 0)); then + break + fi + done + # Merge per-directory runs into one report directory containing a single cov.xml. + if ((exit_code == 0)) && ((${#run_dirs[@]} > 0)); then + rc=0 + "$kcov_bin" --merge "$out_dir" "${run_dirs[@]}" || rc=$? + if ((rc > exit_code)); then + exit_code=$rc + fi + # kcov --merge writes the combined Cobertura report to /kcov-merged/cov.xml. + # Copy it to the canonical /cov.xml so the summary parse below and + # downstream tooling (Coverage Gutters) find a single cov.xml at the documented path. + if [[ -f "$out_dir/kcov-merged/cov.xml" ]]; then + cp -f "$out_dir/kcov-merged/cov.xml" "$out_dir/cov.xml" || true + fi + fi + # Always remove the intermediate run directories, on success or failure. + rm -rf "$runs_dir" + # On overall success, emit the one-line coverage summary when cov.xml is parseable. + if ((exit_code == 0)); then + print_coverage_summary "$out_dir/cov.xml" + fi + return "$exit_code" +}