@danmoisan/drm-copilot-mcp 1.0.8 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmoisan/drm-copilot-mcp",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "Stdio MCP server exposing drm-copilot repo-automation tools.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -46,46 +46,7 @@ param()
46
46
  $script:PrContextArtifactPath = 'artifacts/pr_context.summary.txt'
47
47
  $script:OrchestratorStateCheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
48
48
 
49
- function Invoke-OrchestratorStatePreflight {
50
- <#
51
- .SYNOPSIS
52
- Runs the orchestrator-state validator against the checkpoint and reports pass/fail.
53
- .DESCRIPTION
54
- Mirrors Invoke-RoutingContractValidation (.claude/hooks/validate-orchestrator-output.ps1):
55
- an injectable subprocess scriptblock seam defaults to ``python -m
56
- scripts.dev_tools.validate_orchestration_artifacts orchestrator-state <CheckpointPath>
57
- --require-pr-creation-ready``. A missing checkpoint or --require-pr-creation-ready failure
58
- both surface via the validator's non-zero exit/stderr text; no separate file-existence check
59
- is made, validating pre-PR-creation readiness (steps 5-8, blocked_reason) not full completion.
60
- .OUTPUTS
61
- System.Collections.Hashtable with keys HasErrors (bool) and ErrorText (string).
62
- #>
63
- [CmdletBinding()]
64
- [OutputType([hashtable])]
65
- param(
66
- [Parameter(Mandatory = $false)]
67
- [string] $CheckpointPath = $script:OrchestratorStateCheckpointPath,
68
-
69
- [Parameter(Mandatory = $false)]
70
- [scriptblock] $Invoker = {
71
- param($Path)
72
- $output = & python -m scripts.dev_tools.validate_orchestration_artifacts `
73
- orchestrator-state $Path --require-pr-creation-ready 2>&1
74
- [pscustomobject]@{
75
- ExitCode = $LASTEXITCODE
76
- Output = ($output | Out-String)
77
- }
78
- }
79
- )
80
-
81
- $result = & $Invoker $CheckpointPath
82
- $exitCode = 0
83
- if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'ExitCode')) { $exitCode = [int]$result.ExitCode }
84
- $outputText = ''
85
- if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Output')) { $outputText = ([string]$result.Output).Trim() }
86
-
87
- return @{ HasErrors = ($exitCode -ne 0); ErrorText = $outputText }
88
- }
49
+ Import-Module (Join-Path $PSScriptRoot '../lib/orchestrator-state/OrchestratorState.psm1') -Force
89
50
 
90
51
  function Get-PrContextArtifactExistence {
91
52
  <#
@@ -359,7 +320,7 @@ function Get-PrAuthorBypassReason {
359
320
  # Orchestrator-state preflight: runs inside this same PreToolUse hook (so it cannot be
360
321
  # bypassed by invoking gh pr create/edit directly) before receipt verification.
361
322
  if ($hasBodyFile -and $ContextExists) {
362
- $preflightResult = Invoke-OrchestratorStatePreflight
323
+ $preflightResult = Invoke-OrchestratorStatePreflight -CheckpointPath $script:OrchestratorStateCheckpointPath
363
324
  if ($preflightResult.HasErrors) {
364
325
  $preflightSummary = if ([string]::IsNullOrWhiteSpace($preflightResult.ErrorText)) {
365
326
  "checkpoint missing at $script:OrchestratorStateCheckpointPath"
@@ -38,6 +38,8 @@ param(
38
38
  Set-StrictMode -Version Latest
39
39
  $ErrorActionPreference = 'Stop'
40
40
 
41
+ Import-Module (Join-Path $PSScriptRoot '../lib/orchestrator-state/OrchestratorState.psm1') -Force
42
+
41
43
  function Get-CheckpointFileContent {
42
44
  <#
43
45
  .SYNOPSIS
@@ -179,11 +181,30 @@ function Invoke-RoutingContractValidation {
179
181
  [Parameter(Mandatory = $false)]
180
182
  [scriptblock] $Invoker = {
181
183
  param($Path, $Type)
182
- $output = & python -m scripts.dev_tools.validate_orchestration_artifacts `
183
- $Type $Path --require-complete --require-model-routing 2>&1
184
- [pscustomobject]@{
185
- ExitCode = $LASTEXITCODE
186
- Output = ($output | Out-String)
184
+ # Capability detection: use the authoritative Python CLI when
185
+ # scripts.dev_tools is importable (drm-copilot); otherwise fall back to
186
+ # the portable PowerShell completion module that travels with the
187
+ # pushed-down pack. The portable path performs the presence-level
188
+ # required-once-delegated existence gate and still fails closed.
189
+ if (Test-PythonOrchestratorValidatorAvailable) {
190
+ $output = & python -m scripts.dev_tools.validate_orchestration_artifacts `
191
+ $Type $Path --require-complete --require-model-routing 2>&1
192
+ [pscustomobject]@{
193
+ ExitCode = $LASTEXITCODE
194
+ Output = ($output | Out-String)
195
+ }
196
+ } else {
197
+ # Import the portable completion module only when its function is not
198
+ # already available, so a repeated call (or a test that pre-imports and
199
+ # mocks the function) does not reload the module and reset the seam.
200
+ if (-not (Get-Command -Name Test-OrchestratorStateCompletionReadiness -ErrorAction SilentlyContinue)) {
201
+ Import-Module (Join-Path $PSScriptRoot '../lib/orchestrator-state/OrchestratorStateCompletion.psm1') -Force
202
+ }
203
+ $portable = Test-OrchestratorStateCompletionReadiness -CheckpointPath $Path
204
+ [pscustomobject]@{
205
+ ExitCode = $portable.ExitCode
206
+ Output = $portable.Output
207
+ }
187
208
  }
188
209
  }
189
210
  )
@@ -0,0 +1,209 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Model-routing reference formulas for the orchestrator, ported from the Python references.
4
+
5
+ .DESCRIPTION
6
+ Provides the destination-runtime PowerShell ports of the two self-contained,
7
+ pure model-routing formulas that the `orchestrate` skill instructs the
8
+ orchestrator to run:
9
+
10
+ - Get-ComplexityFloor port of scripts/dev_tools/compute_complexity_floor.py
11
+ - Resolve-DelegationModel port of scripts/dev_tools/resolve_delegation_model.py
12
+
13
+ Both functions are pure and deterministic: they read no file at runtime and
14
+ encode only the fixed band ordering, the base complexity-to-model table, the
15
+ preferred overlay, and the disabled-mode clamp as module-scope constants.
16
+ Those literals are pinned to config/orchestration-routing.json (model_policy /
17
+ model_budget) by a static config-parity Pester test, and the Python modules
18
+ remain the validator's authoritative reference. This module is one half of a
19
+ two-language mirror; it never imports validator logic.
20
+ #>
21
+
22
+ Set-StrictMode -Version Latest
23
+
24
+ # The fixed complexity-band vocabulary, ordered from lowest to highest rigor.
25
+ # The array order defines "higher" and "lower" band comparisons used by the
26
+ # floor computation, mirroring BAND_ORDER in compute_complexity_floor.py.
27
+ $script:BAND_ORDER = @('C1', 'C2', 'C3', 'C4')
28
+
29
+ # The lowest band, returned when no floor signal is present (LOWEST_BAND).
30
+ $script:LOWEST_BAND = 'C1'
31
+
32
+ # Every present floor signal contributes this uniform candidate band, per the
33
+ # model_policy.complexity contract (each [floor] signal contributes C3).
34
+ $script:FLOOR_CANDIDATE_BAND = 'C3'
35
+
36
+ # Floors never exceed this ceiling; C4 is judgment-only and never floor-forced,
37
+ # so the computed floor is clamped to at most C3 (FLOOR_CEILING_BAND).
38
+ $script:FLOOR_CEILING_BAND = 'C3'
39
+
40
+ # The three session-level fable policies (model_budget.fable_policy).
41
+ $script:DISABLED_POLICY = 'disabled'
42
+ $script:PREFERRED_POLICY = 'preferred'
43
+
44
+ # The model tier removed from consideration under the disabled policy, the tier
45
+ # a disabled-mode fable cell clamps down to, and the recorded clamp reason.
46
+ $script:FABLE_MODEL = 'fable'
47
+ $script:DISABLED_CLAMP_MODEL = 'opus'
48
+ $script:DISABLED_CLAMP_REASON = 'fable_disabled'
49
+
50
+ # The base complexity-to-model table applied uniformly across delegated agents
51
+ # (BASE_COMPLEXITY_TO_MODEL). Pinned to model_policy.complexity_to_model.
52
+ $script:BASE_COMPLEXITY_TO_MODEL = @{
53
+ C1 = 'haiku'
54
+ C2 = 'sonnet'
55
+ C3 = 'opus'
56
+ C4 = 'fable'
57
+ }
58
+
59
+ # The agents whose C3 cell the preferred overlay redirects to fable. No other
60
+ # agent and no other band is affected (PREFERRED_OVERLAY_AGENTS).
61
+ $script:PREFERRED_OVERLAY_AGENTS = @(
62
+ 'atomic-planner',
63
+ 'prd-feature',
64
+ 'feature-review',
65
+ 'task-researcher'
66
+ )
67
+
68
+ # The single band and target model the preferred overlay applies.
69
+ $script:PREFERRED_OVERLAY_BAND = 'C3'
70
+ $script:PREFERRED_OVERLAY_MODEL = 'fable'
71
+
72
+
73
+ function Get-ComplexityFloor {
74
+ <#
75
+ .SYNOPSIS
76
+ Compute the deterministic complexity-band floor from present floor signals.
77
+
78
+ .DESCRIPTION
79
+ Faithful PowerShell port of compute_complexity_floor
80
+ (scripts/dev_tools/compute_complexity_floor.py). Returns the deterministic
81
+ lower-bound complexity band implied by the set of present floor signals:
82
+ each present floor signal contributes a candidate band of C3, the floor is
83
+ the maximum triggered candidate band, and the floor never exceeds C3
84
+ (C4 is never floor-forced). With no floor signal present the floor is the
85
+ lowest band C1. The function is pure: it reads no file and does not mutate
86
+ its input, and the result is independent of input ordering.
87
+
88
+ .PARAMETER SignalsPresent
89
+ The names of the present signals flagged [floor] in the
90
+ model_policy.complexity catalog. Every element is treated as a triggered
91
+ floor signal contributing the candidate band C3. An empty collection means
92
+ no floor signal is present.
93
+
94
+ .OUTPUTS
95
+ System.String. The floor band: C1 when no floor signal is present,
96
+ otherwise the maximum triggered candidate band clamped to at most C3.
97
+ C4 is never returned.
98
+ #>
99
+ [CmdletBinding()]
100
+ [OutputType([string])]
101
+ param(
102
+ [Parameter(Mandatory = $true)]
103
+ [AllowEmptyCollection()]
104
+ [string[]] $SignalsPresent
105
+ )
106
+
107
+ # With no present floor signal there is no candidate band to raise the floor
108
+ # above the lowest band, so the floor is C1 (mirrors the empty-input guard).
109
+ if (-not $SignalsPresent -or $SignalsPresent.Count -eq 0) {
110
+ return $script:LOWEST_BAND
111
+ }
112
+
113
+ # Each present floor signal contributes the uniform candidate band; the floor
114
+ # is the maximum triggered candidate rank across all of them. Because every
115
+ # signal contributes the same candidate band, the max equals that rank.
116
+ $candidateRank = $script:BAND_ORDER.IndexOf($script:FLOOR_CANDIDATE_BAND)
117
+ $highestRank = $candidateRank
118
+
119
+ # Clamp with the ceiling rank so the floor can never exceed C3; this is what
120
+ # keeps C4 from ever being floor-forced regardless of how many signals exist.
121
+ $ceilingRank = $script:BAND_ORDER.IndexOf($script:FLOOR_CEILING_BAND)
122
+ $floorRank = [Math]::Min($highestRank, $ceilingRank)
123
+ return $script:BAND_ORDER[$floorRank]
124
+ }
125
+
126
+ function Resolve-DelegationModel {
127
+ <#
128
+ .SYNOPSIS
129
+ Resolve the delegation model tier for an agent, band, and fable policy.
130
+
131
+ .DESCRIPTION
132
+ Faithful PowerShell port of resolve_delegation_model
133
+ (scripts/dev_tools/resolve_delegation_model.py). Applies the model_policy
134
+ selection formula to a single delegation: it computes the pre-clamp
135
+ table_model (the base complexity_to_model table plus any preferred overlay)
136
+ and the post-clamp model, recording the clamp provenance. The preferred
137
+ overlay redirects only the C3 cell to fable and only for the four overlay
138
+ agents; atomic-executor and pr-author C3 cells stay opus under every
139
+ policy. Under the disabled policy a fable table cell clamps to opus with
140
+ clamped_from = fable and clamp_reason = fable_disabled. The function is
141
+ pure: it reads no file and mutates no input.
142
+
143
+ .PARAMETER Agent
144
+ The target delegate agent name (for example atomic-planner). Only
145
+ participates in preferred-overlay eligibility.
146
+
147
+ .PARAMETER Band
148
+ The assessed complexity band, one of C1..C4. Used as the key into the
149
+ base complexity_to_model table. A band outside the table is the PowerShell
150
+ analog of the Python KeyError and causes a terminating error (throw).
151
+
152
+ .PARAMETER FablePolicy
153
+ The session fable policy, one of disabled, available, or preferred.
154
+
155
+ .OUTPUTS
156
+ System.Collections.Hashtable. A hashtable with keys table_model (the
157
+ pre-clamp table lookup, including any overlay), model (the post-clamp
158
+ result), clamped_from (fable when a clamp occurred, else $null), and
159
+ clamp_reason (fable_disabled when a clamp occurred, else $null).
160
+ #>
161
+ [CmdletBinding()]
162
+ [OutputType([hashtable])]
163
+ param(
164
+ [Parameter(Mandatory = $true)]
165
+ [string] $Agent,
166
+ [Parameter(Mandatory = $true)]
167
+ [string] $Band,
168
+ [Parameter(Mandatory = $true)]
169
+ [string] $FablePolicy
170
+ )
171
+
172
+ # The preferred overlay redirects only the C3 cell to fable, and only for the
173
+ # overlay agents; every other case reads the base table unchanged. The three
174
+ # conditions (policy, agent membership, band) must all hold for the overlay.
175
+ if ($FablePolicy -eq $script:PREFERRED_POLICY -and
176
+ $script:PREFERRED_OVERLAY_AGENTS -contains $Agent -and
177
+ $Band -eq $script:PREFERRED_OVERLAY_BAND) {
178
+ $tableModel = $script:PREFERRED_OVERLAY_MODEL
179
+ }
180
+ else {
181
+ # A band outside the base table is the PowerShell analog of the Python
182
+ # KeyError: fail fast rather than return a silently wrong value.
183
+ if (-not $script:BASE_COMPLEXITY_TO_MODEL.ContainsKey($Band)) {
184
+ throw "Unknown complexity band '$Band'; expected one of $($script:BAND_ORDER -join ', ')."
185
+ }
186
+ $tableModel = $script:BASE_COMPLEXITY_TO_MODEL[$Band]
187
+ }
188
+
189
+ # Under the disabled policy, fable is removed from consideration: a fable
190
+ # table cell clamps down to opus and records the clamp provenance.
191
+ if ($FablePolicy -eq $script:DISABLED_POLICY -and $tableModel -eq $script:FABLE_MODEL) {
192
+ return @{
193
+ table_model = $tableModel
194
+ model = $script:DISABLED_CLAMP_MODEL
195
+ clamped_from = $script:FABLE_MODEL
196
+ clamp_reason = $script:DISABLED_CLAMP_REASON
197
+ }
198
+ }
199
+
200
+ # No clamp applies: the resolved model is the table model verbatim.
201
+ return @{
202
+ table_model = $tableModel
203
+ model = $tableModel
204
+ clamped_from = $null
205
+ clamp_reason = $null
206
+ }
207
+ }
208
+
209
+ Export-ModuleMember -Function Get-ComplexityFloor, Resolve-DelegationModel
@@ -0,0 +1,485 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Portable orchestrator-state checkpoint checks for pushed-down enforcement hooks.
4
+
5
+ .DESCRIPTION
6
+ Provides a self-contained PowerShell implementation of the pushed-down-relevant
7
+ orchestrator-state checkpoint validations so the `.claude` enforcement hooks work
8
+ in consumer repositories that do not ship `scripts/dev_tools` (the authoritative
9
+ Python validator). This module mirrors the portable pattern of
10
+ `.claude/lib/model-routing/ModelRouting.psm1`.
11
+
12
+ It implements PR-creation-readiness parity with
13
+ `scripts/dev_tools/_orchestrator_state_pr_creation_readiness.py` and the base
14
+ checkpoint-presence checks (required keys, step-status validity, blocked_reason
15
+ validity) from `scripts/dev_tools/validate_orchestrator_state.py`. The base
16
+ constants below are pinned to `REQUIRED_STATE_KEYS`, `STEP_STATUS_KEYS`,
17
+ `VALID_STEP_STATUS`, and `VALID_BLOCKED_REASONS` in that validator.
18
+
19
+ Every public function FAILS CLOSED: a missing checkpoint file, invalid JSON, a
20
+ missing required key, an invalid step status, or an unmet readiness condition all
21
+ yield a non-zero ExitCode with a non-empty Output message. The Python validator
22
+ remains the authoritative reference; this module is the destination-runtime
23
+ mirror used only when the Python module is not importable.
24
+
25
+ This module also hosts the capability-detection probe
26
+ (Test-PythonOrchestratorValidatorAvailable), shared by both pushed-down hooks
27
+ (.claude/hooks/enforce-pr-author-skill.ps1 and
28
+ .claude/hooks/validate-orchestrator-output.ps1) so neither hook duplicates it
29
+ locally, and the PR-creation preflight orchestration helper
30
+ (Invoke-OrchestratorStatePreflight) consumed by enforce-pr-author-skill.ps1.
31
+ #>
32
+
33
+ Set-StrictMode -Version Latest
34
+
35
+ # The canonical top-level checkpoint keys required by the primary validator.
36
+ # Pinned to REQUIRED_STATE_KEYS in scripts/dev_tools/validate_orchestrator_state.py.
37
+ $script:REQUIRED_STATE_KEYS = @(
38
+ 'objective',
39
+ 'change_budget_estimate',
40
+ 'path_selected',
41
+ 'promotion-type',
42
+ 'short-name',
43
+ 'relativeFile',
44
+ 'long-name',
45
+ 'issue-num',
46
+ 'feature-folder',
47
+ 'work-mode',
48
+ 'plan-path',
49
+ 'completed_steps',
50
+ 'next_step',
51
+ 'last_updated',
52
+ 'step5_status',
53
+ 'step6_status',
54
+ 'step7_status',
55
+ 'step8_status',
56
+ 'step9_status',
57
+ 'step10_status',
58
+ 'delegation_receipts',
59
+ 'blocked_reason'
60
+ )
61
+
62
+ # The lifecycle step-status keys whose value, when present, must be a member of
63
+ # VALID_STEP_STATUS. Pinned to STEP_STATUS_KEYS in the primary validator.
64
+ $script:STEP_STATUS_KEYS = @(
65
+ 'step5_status',
66
+ 'step6_status',
67
+ 'step7_status',
68
+ 'step8_status',
69
+ 'step9_status',
70
+ 'step10_status'
71
+ )
72
+
73
+ # The allowed step-status vocabulary. Pinned to VALID_STEP_STATUS in the primary
74
+ # validator.
75
+ $script:VALID_STEP_STATUS = @(
76
+ 'not-applicable',
77
+ 'pending',
78
+ 'delegated',
79
+ 'verified',
80
+ 'blocked',
81
+ 'not_started',
82
+ 'in_progress',
83
+ 'completed'
84
+ )
85
+
86
+ # The allowed blocked_reason vocabulary. Pinned to VALID_BLOCKED_REASONS in the
87
+ # primary validator.
88
+ $script:VALID_BLOCKED_REASONS = @(
89
+ 'none',
90
+ 'spawn_agent_unavailable',
91
+ 'delegation_launch_failed',
92
+ 'delegate_no_receipt',
93
+ 'delegate_contract_incomplete',
94
+ 'validator_failed',
95
+ 'user_requested_stop'
96
+ )
97
+
98
+ # The upstream steps that must not be pending/blocked before the first PR creation
99
+ # of a branch. Pinned to PR_CREATION_READY_STEP_KEYS in
100
+ # _orchestrator_state_pr_creation_readiness.py (deliberately narrower than the full
101
+ # step set: steps 9-10 can only populate after PR creation and CI have run).
102
+ $script:PR_CREATION_READY_STEP_KEYS = @(
103
+ 'step5_status',
104
+ 'step6_status',
105
+ 'step7_status',
106
+ 'step8_status'
107
+ )
108
+
109
+ # The checkpoint list fields that must be empty (or absent) before the first PR
110
+ # creation. Pinned to PR_CREATION_READY_EMPTY_LIST_KEYS in the Python reference.
111
+ $script:PR_CREATION_READY_EMPTY_LIST_KEYS = @(
112
+ 'local_execution_overrides',
113
+ 'delegation_bypasses'
114
+ )
115
+
116
+
117
+ function Get-OrchestratorStateCheckpoint {
118
+ <#
119
+ .SYNOPSIS
120
+ Load and parse an orchestrator-state checkpoint, failing closed on error.
121
+ .DESCRIPTION
122
+ Private load helper. Reads the checkpoint at -CheckpointPath and parses it as
123
+ JSON. It never throws to the caller: a missing file or unparseable/invalid
124
+ JSON is reported through a structured result with Ok = $false and a non-empty
125
+ Error string, so callers can translate the failure into a fail-closed
126
+ non-zero ExitCode.
127
+ .PARAMETER CheckpointPath
128
+ The path to the orchestrator-state checkpoint JSON file.
129
+ .OUTPUTS
130
+ System.Collections.Hashtable with keys:
131
+ - Ok (bool): $true when the file exists and parsed to a JSON object.
132
+ - State (object): the parsed PSCustomObject on success, otherwise $null.
133
+ - Error (string): a non-empty failure message on failure, otherwise ''.
134
+ #>
135
+ [CmdletBinding()]
136
+ [OutputType([hashtable])]
137
+ param(
138
+ [Parameter(Mandatory = $true)]
139
+ [string] $CheckpointPath
140
+ )
141
+
142
+ # A missing checkpoint file is a fail-closed condition: there is no state to
143
+ # validate, so readiness cannot be established.
144
+ if (-not (Test-Path -LiteralPath $CheckpointPath -PathType Leaf)) {
145
+ return @{
146
+ Ok = $false
147
+ State = $null
148
+ Error = "Checkpoint file '$CheckpointPath' does not exist."
149
+ }
150
+ }
151
+
152
+ $raw = Get-Content -LiteralPath $CheckpointPath -Raw -ErrorAction Stop
153
+
154
+ # An empty checkpoint carries no state; treat it as fail-closed rather than
155
+ # letting ConvertFrom-Json return $null silently.
156
+ if ([string]::IsNullOrWhiteSpace($raw)) {
157
+ return @{
158
+ Ok = $false
159
+ State = $null
160
+ Error = "Checkpoint file '$CheckpointPath' is empty."
161
+ }
162
+ }
163
+
164
+ # Parse the checkpoint text. Invalid JSON is a fail-closed condition, caught here
165
+ # so the caller receives a message instead of a terminating error.
166
+ try {
167
+ $state = $raw | ConvertFrom-Json -ErrorAction Stop
168
+ } catch {
169
+ return @{
170
+ Ok = $false
171
+ State = $null
172
+ Error = "Checkpoint file '$CheckpointPath' is not valid JSON: $($_.Exception.Message)"
173
+ }
174
+ }
175
+
176
+ # A JSON scalar or array root is not a checkpoint object; require an object so
177
+ # the presence checks can inspect named fields.
178
+ if ($state -isnot [System.Management.Automation.PSCustomObject]) {
179
+ return @{
180
+ Ok = $false
181
+ State = $null
182
+ Error = "Checkpoint file '$CheckpointPath' root must be a JSON object."
183
+ }
184
+ }
185
+
186
+ return @{ Ok = $true; State = $state; Error = '' }
187
+ }
188
+
189
+ function Get-OrchestratorStateField {
190
+ <#
191
+ .SYNOPSIS
192
+ Read a checkpoint field, distinguishing an absent key from a null value.
193
+ .DESCRIPTION
194
+ Private accessor that safely reads a named property from the parsed
195
+ checkpoint object under Set-StrictMode, where accessing an undefined property
196
+ would otherwise throw. Returns whether the key is present and its value,
197
+ mirroring the semantics of Python's ``dict.get`` (absent and null both read
198
+ as "no value" for the readiness checks).
199
+ .PARAMETER State
200
+ The parsed checkpoint PSCustomObject.
201
+ .PARAMETER Name
202
+ The property name to read.
203
+ .OUTPUTS
204
+ System.Collections.Hashtable with keys Present (bool) and Value (object).
205
+ #>
206
+ [CmdletBinding()]
207
+ [OutputType([hashtable])]
208
+ param(
209
+ [Parameter(Mandatory = $true)]
210
+ [psobject] $State,
211
+
212
+ [Parameter(Mandatory = $true)]
213
+ [string] $Name
214
+ )
215
+
216
+ $names = @($State.PSObject.Properties.Name)
217
+ if ($names -contains $Name) {
218
+ return @{ Present = $true; Value = $State.$Name }
219
+ }
220
+ return @{ Present = $false; Value = $null }
221
+ }
222
+
223
+ function Get-OrchestratorStateBasePresenceError {
224
+ <#
225
+ .SYNOPSIS
226
+ Return the base checkpoint-presence errors, mirroring the primary validator.
227
+ .DESCRIPTION
228
+ Private base check. Emits one error string per missing required key, one per
229
+ step5_status..step10_status value outside VALID_STEP_STATUS, and one when
230
+ blocked_reason is present with a value outside VALID_BLOCKED_REASONS. This
231
+ mirrors the base block of scripts/dev_tools/validate_orchestrator_state.py
232
+ (required keys, step-status validity, blocked_reason validity) that runs
233
+ before any mode-specific gate.
234
+ .PARAMETER State
235
+ The parsed checkpoint PSCustomObject.
236
+ .OUTPUTS
237
+ System.String[] - zero or more error strings; empty when the base shape is valid.
238
+ #>
239
+ [CmdletBinding()]
240
+ [OutputType([string[]])]
241
+ param(
242
+ [Parameter(Mandatory = $true)]
243
+ [psobject] $State
244
+ )
245
+
246
+ $errors = [System.Collections.Generic.List[string]]::new()
247
+ $names = @($State.PSObject.Properties.Name)
248
+
249
+ # Require every canonical top-level field; a missing key is reported individually
250
+ # so the operator sees exactly which fields are absent.
251
+ foreach ($key in $script:REQUIRED_STATE_KEYS) {
252
+ if ($names -notcontains $key) {
253
+ $errors.Add("Checkpoint missing required key: $key")
254
+ }
255
+ }
256
+
257
+ # Every present step status must be a member of the allowed vocabulary; an absent
258
+ # step key contributes no error (mirrors the primary validator's None guard).
259
+ foreach ($key in $script:STEP_STATUS_KEYS) {
260
+ $field = Get-OrchestratorStateField -State $State -Name $key
261
+ if ($field.Present -and $null -ne $field.Value -and
262
+ ($script:VALID_STEP_STATUS -notcontains [string]$field.Value)) {
263
+ $errors.Add("Checkpoint has invalid $key`: $($field.Value)")
264
+ }
265
+ }
266
+
267
+ # A present, non-null blocked_reason must be a member of the allowed vocabulary.
268
+ $blocked = Get-OrchestratorStateField -State $State -Name 'blocked_reason'
269
+ if ($blocked.Present -and $null -ne $blocked.Value -and
270
+ ($script:VALID_BLOCKED_REASONS -notcontains [string]$blocked.Value)) {
271
+ $errors.Add("Checkpoint has invalid blocked_reason: $($blocked.Value)")
272
+ }
273
+
274
+ return $errors.ToArray()
275
+ }
276
+
277
+ function Get-OrchestratorStatePrCreationReadinessError {
278
+ <#
279
+ .SYNOPSIS
280
+ Return the PR-creation-readiness errors, parity with the Python reference.
281
+ .DESCRIPTION
282
+ Private readiness check mirroring
283
+ validate_orchestrator_state_pr_creation_readiness in
284
+ _orchestrator_state_pr_creation_readiness.py: steps 5-8 must not be
285
+ pending/blocked; blocked_reason must be `none` or absent; and the
286
+ local_execution_overrides / delegation_bypasses lists must be empty when
287
+ present. It does not enforce completion, CI, PR, or routing-contract gates.
288
+ .PARAMETER State
289
+ The parsed checkpoint PSCustomObject.
290
+ .OUTPUTS
291
+ System.String[] - zero or more error strings; empty when ready for PR creation.
292
+ #>
293
+ [CmdletBinding()]
294
+ [OutputType([string[]])]
295
+ param(
296
+ [Parameter(Mandatory = $true)]
297
+ [psobject] $State
298
+ )
299
+
300
+ $errors = [System.Collections.Generic.List[string]]::new()
301
+
302
+ # Reject an upstream step recorded as pending or blocked; steps 5-8 must have
303
+ # finished before the first PR of a branch is created.
304
+ foreach ($key in $script:PR_CREATION_READY_STEP_KEYS) {
305
+ $field = Get-OrchestratorStateField -State $State -Name $key
306
+ if ($field.Present -and ($field.Value -eq 'pending' -or $field.Value -eq 'blocked')) {
307
+ $errors.Add("Checkpoint PR-creation readiness validation failed: $key is $($field.Value).")
308
+ }
309
+ }
310
+
311
+ # blocked_reason must read as clear: absent, null, or the literal 'none'. Any
312
+ # other recorded reason means the branch is not ready for PR creation.
313
+ $blocked = Get-OrchestratorStateField -State $State -Name 'blocked_reason'
314
+ if ($blocked.Present -and $null -ne $blocked.Value -and ([string]$blocked.Value -ne 'none')) {
315
+ $errors.Add('Checkpoint PR-creation readiness validation failed: blocked_reason is not `none`.')
316
+ }
317
+
318
+ # Each override list must be empty when present: a present non-list value, or a
319
+ # present non-empty list, means overrides/bypasses were recorded (mirrors the
320
+ # Python `value is not None and (not isinstance(value, list) or value)` guard).
321
+ foreach ($key in $script:PR_CREATION_READY_EMPTY_LIST_KEYS) {
322
+ $field = Get-OrchestratorStateField -State $State -Name $key
323
+ if ($field.Present -and $null -ne $field.Value) {
324
+ $isList = $field.Value -is [System.Array]
325
+ if (-not $isList -or @($field.Value).Count -gt 0) {
326
+ $errors.Add("Checkpoint PR-creation readiness validation failed: $key must be an empty list when present.")
327
+ }
328
+ }
329
+ }
330
+
331
+ return $errors.ToArray()
332
+ }
333
+
334
+ function Test-PythonOrchestratorValidatorAvailable {
335
+ <#
336
+ .SYNOPSIS
337
+ Probe whether the authoritative Python orchestrator-state validator is importable.
338
+ .DESCRIPTION
339
+ Capability-detection seam. Returns $true only when
340
+ ``python -c "import scripts.dev_tools.validate_orchestration_artifacts"`` exits 0,
341
+ indicating the authoritative Python validator ships in this repository (drm-copilot).
342
+ Returns $false on any non-zero exit or error, so a consumer repository that received
343
+ only the pushed-down `.claude` pack (no `scripts/dev_tools`) routes to the portable
344
+ PowerShell module. Any probe failure routes to the portable path, which itself fails
345
+ closed on bad checkpoints, preserving fail-closed semantics in both branches. Tests
346
+ mock this seam directly; they never mock `python`.
347
+ .OUTPUTS
348
+ System.Boolean
349
+ #>
350
+ [CmdletBinding()]
351
+ [OutputType([bool])]
352
+ param()
353
+
354
+ try {
355
+ & python -c 'import scripts.dev_tools.validate_orchestration_artifacts' 2>&1 | Out-Null
356
+ return ($LASTEXITCODE -eq 0)
357
+ } catch {
358
+ return $false
359
+ }
360
+ }
361
+
362
+ function Test-OrchestratorStatePrCreationReadiness {
363
+ <#
364
+ .SYNOPSIS
365
+ Validate a checkpoint is ready for the first `gh pr create` of a branch.
366
+ .DESCRIPTION
367
+ Public entry point used by the pushed-down enforce-pr-author-skill hook when
368
+ the authoritative Python validator is not importable. Loads the checkpoint
369
+ (fail-closed on missing file / invalid JSON), runs the base-presence check
370
+ (required keys, step-status validity, blocked_reason validity), then runs the
371
+ PR-creation-readiness parity check. Returns a hashtable compatible with the
372
+ hook's existing invoker contract: ExitCode is 1 whenever any error is present,
373
+ and Output carries the newline-joined error text (empty on success).
374
+ .PARAMETER CheckpointPath
375
+ The path to the orchestrator-state checkpoint JSON file.
376
+ .OUTPUTS
377
+ System.Collections.Hashtable with keys ExitCode (int, 0 or 1) and Output (string).
378
+ #>
379
+ [CmdletBinding()]
380
+ [OutputType([hashtable])]
381
+ param(
382
+ [Parameter(Mandatory = $true)]
383
+ [string] $CheckpointPath
384
+ )
385
+
386
+ # Fail closed when the checkpoint cannot be loaded: the load error is the whole
387
+ # output and ExitCode is 1.
388
+ $loaded = Get-OrchestratorStateCheckpoint -CheckpointPath $CheckpointPath
389
+ if (-not $loaded.Ok) {
390
+ return @{ ExitCode = 1; Output = $loaded.Error }
391
+ }
392
+
393
+ # Accumulate base-presence errors and readiness errors; any error yields a
394
+ # non-zero ExitCode so the hook blocks PR creation.
395
+ $errors = [System.Collections.Generic.List[string]]::new()
396
+ $errors.AddRange([string[]]@(Get-OrchestratorStateBasePresenceError -State $loaded.State))
397
+ $errors.AddRange([string[]]@(Get-OrchestratorStatePrCreationReadinessError -State $loaded.State))
398
+
399
+ if ($errors.Count -gt 0) {
400
+ return @{ ExitCode = 1; Output = ($errors -join [System.Environment]::NewLine) }
401
+ }
402
+
403
+ return @{ ExitCode = 0; Output = '' }
404
+ }
405
+
406
+ function Invoke-OrchestratorStatePreflight {
407
+ <#
408
+ .SYNOPSIS
409
+ Runs the orchestrator-state validator against the checkpoint and reports pass/fail.
410
+ .DESCRIPTION
411
+ Shared by the pushed-down enforce-pr-author-skill hook. Mirrors
412
+ Invoke-RoutingContractValidation (.claude/hooks/validate-orchestrator-output.ps1):
413
+ an injectable subprocess scriptblock seam defaults to ``python -m
414
+ scripts.dev_tools.validate_orchestration_artifacts orchestrator-state <CheckpointPath>
415
+ --require-pr-creation-ready``. A missing checkpoint or --require-pr-creation-ready failure
416
+ both surface via the validator's non-zero exit/stderr text; no separate file-existence check
417
+ is made, validating pre-PR-creation readiness (steps 5-8, blocked_reason) not full completion.
418
+ .PARAMETER CheckpointPath
419
+ The path to the orchestrator-state checkpoint JSON file. Callers pass their own
420
+ checkpoint-path variable explicitly; the default below is only used when a caller omits
421
+ the parameter.
422
+ .OUTPUTS
423
+ System.Collections.Hashtable with keys HasErrors (bool) and ErrorText (string).
424
+ #>
425
+ [CmdletBinding()]
426
+ [OutputType([hashtable])]
427
+ param(
428
+ [Parameter(Mandatory = $false)]
429
+ [string] $CheckpointPath = 'artifacts/orchestration/orchestrator-state.json',
430
+
431
+ [Parameter(Mandatory = $false)]
432
+ [scriptblock] $Invoker = {
433
+ param($Path)
434
+ # Capability detection: use the authoritative Python CLI when
435
+ # scripts.dev_tools is importable (drm-copilot); otherwise fall back to
436
+ # the portable PowerShell function that lives alongside this one in the
437
+ # pushed-down pack.
438
+ if (Test-PythonOrchestratorValidatorAvailable) {
439
+ $output = & python -m scripts.dev_tools.validate_orchestration_artifacts `
440
+ orchestrator-state $Path --require-pr-creation-ready 2>&1
441
+ [pscustomobject]@{
442
+ ExitCode = $LASTEXITCODE
443
+ Output = ($output | Out-String)
444
+ }
445
+ } else {
446
+ $portable = Test-OrchestratorStatePrCreationReadiness -CheckpointPath $Path
447
+ [pscustomobject]@{
448
+ ExitCode = $portable.ExitCode
449
+ Output = $portable.Output
450
+ }
451
+ }
452
+ }
453
+ )
454
+
455
+ $result = & $Invoker $CheckpointPath
456
+ # Under this module's Set-StrictMode -Version Latest, member-enumerating .Name directly over
457
+ # a zero-property PSCustomObject throws (a PowerShell strict-mode gotcha not present in the
458
+ # hook's un-strict scope this code was moved from), so the property collection is counted
459
+ # before .Name is ever accessed.
460
+ $resultPropertyNames = @()
461
+ if ($null -ne $result -and @($result.PSObject.Properties).Count -gt 0) {
462
+ $resultPropertyNames = @($result.PSObject.Properties.Name)
463
+ }
464
+ $exitCode = 0
465
+ if ($resultPropertyNames -contains 'ExitCode') { $exitCode = [int]$result.ExitCode }
466
+ $outputText = ''
467
+ if ($resultPropertyNames -contains 'Output') { $outputText = ([string]$result.Output).Trim() }
468
+
469
+ return @{ HasErrors = ($exitCode -ne 0); ErrorText = $outputText }
470
+ }
471
+
472
+ # Export the public readiness entry point plus the reusable load, field-accessor,
473
+ # and base-presence primitives so the sibling OrchestratorStateCompletion module can
474
+ # consume them via Import-Module without duplicating the shared parsing and
475
+ # base-check logic. Test-PythonOrchestratorValidatorAvailable and
476
+ # Invoke-OrchestratorStatePreflight are exported so both pushed-down hooks
477
+ # (enforce-pr-author-skill.ps1, validate-orchestrator-output.ps1) can consume them
478
+ # without duplicating the capability probe or the PR-creation preflight orchestration.
479
+ Export-ModuleMember -Function `
480
+ Test-OrchestratorStatePrCreationReadiness, `
481
+ Get-OrchestratorStateCheckpoint, `
482
+ Get-OrchestratorStateField, `
483
+ Get-OrchestratorStateBasePresenceError, `
484
+ Test-PythonOrchestratorValidatorAvailable, `
485
+ Invoke-OrchestratorStatePreflight
@@ -0,0 +1,243 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Portable completion-gate presence checks for the orchestrator-state checkpoint.
4
+
5
+ .DESCRIPTION
6
+ Provides the destination-runtime PowerShell mirror of the completion-gate
7
+ presence checks the pushed-down validate-orchestrator-output hook needs when the
8
+ authoritative Python validator (`scripts/dev_tools`) is not importable. It
9
+ reuses the shared load, field-accessor, and base-presence primitives from the
10
+ sibling `OrchestratorState.psm1` and imports `.claude/lib/model-routing/ModelRouting.psm1`
11
+ so per-receipt model formulas are available where practical.
12
+
13
+ The single public function `Test-OrchestratorStateCompletionReadiness` fails
14
+ closed on a missing checkpoint file, invalid JSON, or an invalid base shape, then
15
+ applies the model-routing "required once delegated" existence gate - the
16
+ delegated-agent set (derived from `delegation_receipts[].agent_name` plus a
17
+ delegating `next_step`) must be a subset of `model_routing_receipts[].agent` -
18
+ mirroring `scripts/dev_tools/_orchestrator_state_model_routing_gate.py`. Deep
19
+ per-receipt routing-contract correctness that requires full Python authority is a
20
+ documented Non-Goal for the portable path; the gate performs the presence-level
21
+ existence check and reports missing receipts with error text containing the
22
+ literal token `model_routing_receipts`, so the completion hook maps a failure to
23
+ its `MODEL_ROUTING_BLOCKED:` block reason. The Python validator remains
24
+ authoritative; this module is the fallback mirror only.
25
+ #>
26
+
27
+ Set-StrictMode -Version Latest
28
+
29
+ # Import the sibling shared module and the portable model-routing module, resolved
30
+ # relative to this module's directory so the imports travel with the pushed-down
31
+ # pack regardless of the consumer repository's working directory.
32
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorState.psm1') -Force
33
+ $script:ModelRoutingModulePath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath (Join-Path -Path 'model-routing' -ChildPath 'ModelRouting.psm1')
34
+ Import-Module $script:ModelRoutingModulePath -Force
35
+
36
+ # The subagent types delegated via the Agent tool that can be named by a delegating
37
+ # next_step. Pinned to _DELEGATING_AGENTS in
38
+ # scripts/dev_tools/_orchestrator_state_model_routing_gate.py. The `orchestrator`
39
+ # type is deliberately excluded: it is the caller, never a routing-receipt target.
40
+ $script:DELEGATING_AGENTS = @(
41
+ 'atomic-planner',
42
+ 'atomic-executor',
43
+ 'feature-review',
44
+ 'task-researcher',
45
+ 'prd-feature',
46
+ 'pr-author'
47
+ )
48
+
49
+ # Checkpoint keys the gate reads to derive the delegated-agent and receipt-agent sets.
50
+ $script:DELEGATION_RECEIPTS_KEY = 'delegation_receipts'
51
+ $script:MODEL_ROUTING_RECEIPTS_KEY = 'model_routing_receipts'
52
+ $script:NEXT_STEP_KEY = 'next_step'
53
+
54
+
55
+ function Get-OrchestratorStateDelegatedAgent {
56
+ <#
57
+ .SYNOPSIS
58
+ Derive the set of agents a checkpoint has delegated (or is about to).
59
+ .DESCRIPTION
60
+ Private helper mirroring ``_delegated_agents`` in the Python gate. Collects
61
+ each well-formed ``delegation_receipts[]`` entry's non-empty ``agent_name``
62
+ plus the agent implied by a ``next_step`` that names a recognized delegating
63
+ agent. The list form of ``delegation_receipts`` is the authoritative "a
64
+ delegation happened" record; the namespaced (promotion) object form carries
65
+ no agent_name list and contributes no delegated agents.
66
+ .PARAMETER State
67
+ The parsed checkpoint PSCustomObject.
68
+ .OUTPUTS
69
+ System.String[] - the delegated-agent names (may be empty).
70
+ #>
71
+ [CmdletBinding()]
72
+ [OutputType([string[]])]
73
+ param(
74
+ [Parameter(Mandatory = $true)]
75
+ [psobject] $State
76
+ )
77
+
78
+ $agents = [System.Collections.Generic.HashSet[string]]::new()
79
+
80
+ # Collect each list-form delegation receipt's non-empty agent_name. A non-list
81
+ # (namespaced) delegation_receipts value contributes nothing here.
82
+ $receiptsField = Get-OrchestratorStateField -State $State -Name $script:DELEGATION_RECEIPTS_KEY
83
+ if ($receiptsField.Present -and ($receiptsField.Value -is [System.Array])) {
84
+ foreach ($receipt in $receiptsField.Value) {
85
+ if ($receipt -is [System.Management.Automation.PSCustomObject]) {
86
+ $nameField = Get-OrchestratorStateField -State $receipt -Name 'agent_name'
87
+ if ($nameField.Present -and $null -ne $nameField.Value -and
88
+ -not [string]::IsNullOrWhiteSpace([string]$nameField.Value)) {
89
+ [void]$agents.Add([string]$nameField.Value)
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ # A delegating next_step names the upcoming delegation that may not yet have a
96
+ # receipt; include it only when it matches a recognized delegating agent so a
97
+ # non-delegating label (for example "complete") never triggers the gate.
98
+ $nextStepField = Get-OrchestratorStateField -State $State -Name $script:NEXT_STEP_KEY
99
+ if ($nextStepField.Present -and $null -ne $nextStepField.Value -and
100
+ ($script:DELEGATING_AGENTS -contains [string]$nextStepField.Value)) {
101
+ [void]$agents.Add([string]$nextStepField.Value)
102
+ }
103
+
104
+ return [string[]]@($agents)
105
+ }
106
+
107
+ function Get-OrchestratorStateRoutingReceiptAgent {
108
+ <#
109
+ .SYNOPSIS
110
+ Collect the set of agents that carry a model-routing receipt.
111
+ .DESCRIPTION
112
+ Private helper mirroring the receipt-agent harvest in the Python gate. Reads
113
+ the checkpoint's ``model_routing_receipts[]`` array and returns the set of
114
+ non-empty ``agent`` values present on well-formed receipt objects. A non-list
115
+ value contributes no agents (the existence gate then reports every delegated
116
+ agent as unreceipted, preserving fail-closed semantics).
117
+ .PARAMETER State
118
+ The parsed checkpoint PSCustomObject.
119
+ .OUTPUTS
120
+ System.String[] - the receipt-agent names (may be empty).
121
+ #>
122
+ [CmdletBinding()]
123
+ [OutputType([string[]])]
124
+ param(
125
+ [Parameter(Mandatory = $true)]
126
+ [psobject] $State
127
+ )
128
+
129
+ $agents = [System.Collections.Generic.HashSet[string]]::new()
130
+
131
+ $receiptsField = Get-OrchestratorStateField -State $State -Name $script:MODEL_ROUTING_RECEIPTS_KEY
132
+ if ($receiptsField.Present -and ($receiptsField.Value -is [System.Array])) {
133
+ # Record each well-formed receipt's non-empty agent so the existence gate can
134
+ # test the delegated-agent set against it.
135
+ foreach ($receipt in $receiptsField.Value) {
136
+ if ($receipt -is [System.Management.Automation.PSCustomObject]) {
137
+ $agentField = Get-OrchestratorStateField -State $receipt -Name 'agent'
138
+ if ($agentField.Present -and $null -ne $agentField.Value -and
139
+ -not [string]::IsNullOrWhiteSpace([string]$agentField.Value)) {
140
+ [void]$agents.Add([string]$agentField.Value)
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ return [string[]]@($agents)
147
+ }
148
+
149
+ function Get-OrchestratorStateModelRoutingGateError {
150
+ <#
151
+ .SYNOPSIS
152
+ Return the required-once-delegated existence-gate errors.
153
+ .DESCRIPTION
154
+ Private gate mirroring ``validate_model_routing_gate`` at the presence level:
155
+ it fires only when the checkpoint has delegated (or is about to delegate to)
156
+ at least one agent, then reports one error per delegated agent that lacks a
157
+ matching ``model_routing_receipts[]`` entry. A delegation-free checkpoint
158
+ contributes zero errors, preserving backward compatibility. Each error names
159
+ the literal token ``model_routing_receipts`` so the completion hook routes a
160
+ failure to ``MODEL_ROUTING_BLOCKED:``.
161
+ .PARAMETER State
162
+ The parsed checkpoint PSCustomObject.
163
+ .OUTPUTS
164
+ System.String[] - zero or more error strings; empty when the gate is satisfied
165
+ or does not fire.
166
+ #>
167
+ [CmdletBinding()]
168
+ [OutputType([string[]])]
169
+ param(
170
+ [Parameter(Mandatory = $true)]
171
+ [psobject] $State
172
+ )
173
+
174
+ $errors = [System.Collections.Generic.List[string]]::new()
175
+
176
+ # Backward-compat gate: a delegation-free checkpoint imposes no routing-receipt
177
+ # requirement, so return early with no errors.
178
+ $delegated = @(Get-OrchestratorStateDelegatedAgent -State $State)
179
+ if ($delegated.Count -eq 0) {
180
+ return $errors.ToArray()
181
+ }
182
+
183
+ $receiptAgents = @(Get-OrchestratorStateRoutingReceiptAgent -State $State)
184
+
185
+ # Existence invariant: the routing-receipt agent set must be a superset of the
186
+ # delegated-agent set. Report each delegated agent with no receipt, sorted for
187
+ # deterministic error ordering.
188
+ $missing = $delegated | Where-Object { $receiptAgents -notcontains $_ } | Sort-Object
189
+ foreach ($agent in $missing) {
190
+ $errors.Add("Checkpoint model_routing_receipts is missing a receipt for delegated agent: $agent.")
191
+ }
192
+
193
+ return $errors.ToArray()
194
+ }
195
+
196
+ function Test-OrchestratorStateCompletionReadiness {
197
+ <#
198
+ .SYNOPSIS
199
+ Validate a checkpoint satisfies the portable completion-gate presence checks.
200
+ .DESCRIPTION
201
+ Public entry point used by the pushed-down validate-orchestrator-output hook
202
+ when the authoritative Python validator is not importable. Loads the
203
+ checkpoint (fail-closed on missing file / invalid JSON / invalid base shape),
204
+ runs the base-presence check (required keys, step-status validity,
205
+ blocked_reason validity), then applies the model-routing required-once-
206
+ delegated existence gate. Returns a hashtable compatible with the hook's
207
+ invoker contract: ExitCode is 1 whenever any error is present, and Output
208
+ carries the newline-joined error text (empty on success). A missing routing
209
+ receipt yields error text containing ``model_routing_receipts`` so the hook
210
+ surfaces it under ``MODEL_ROUTING_BLOCKED:``.
211
+ .PARAMETER CheckpointPath
212
+ The path to the orchestrator-state checkpoint JSON file.
213
+ .OUTPUTS
214
+ System.Collections.Hashtable with keys ExitCode (int, 0 or 1) and Output (string).
215
+ #>
216
+ [CmdletBinding()]
217
+ [OutputType([hashtable])]
218
+ param(
219
+ [Parameter(Mandatory = $true)]
220
+ [string] $CheckpointPath
221
+ )
222
+
223
+ # Fail closed when the checkpoint cannot be loaded: the load error is the whole
224
+ # output and ExitCode is 1.
225
+ $loaded = Get-OrchestratorStateCheckpoint -CheckpointPath $CheckpointPath
226
+ if (-not $loaded.Ok) {
227
+ return @{ ExitCode = 1; Output = $loaded.Error }
228
+ }
229
+
230
+ # Accumulate base-presence errors and existence-gate errors; any error yields a
231
+ # non-zero ExitCode so the completion hook blocks DONE.
232
+ $errors = [System.Collections.Generic.List[string]]::new()
233
+ $errors.AddRange([string[]]@(Get-OrchestratorStateBasePresenceError -State $loaded.State))
234
+ $errors.AddRange([string[]]@(Get-OrchestratorStateModelRoutingGateError -State $loaded.State))
235
+
236
+ if ($errors.Count -gt 0) {
237
+ return @{ ExitCode = 1; Output = ($errors -join [System.Environment]::NewLine) }
238
+ }
239
+
240
+ return @{ ExitCode = 0; Output = '' }
241
+ }
242
+
243
+ Export-ModuleMember -Function Test-OrchestratorStateCompletionReadiness
@@ -115,9 +115,16 @@ The child's own `orchestrator` reads this line and applies the two-axis model-se
115
115
  documented in `.claude/skills/orchestrate/SKILL.md` (`## Model Selection`): it assesses a
116
116
  judgment-based `complexity_band`, records `complexity_assessments[]` and `model_routing_receipts[]`,
117
117
  and resolves each delegation's model tier under the given `fable_policy`. The two canonical, tested
118
- reference implementations are `scripts/dev_tools/compute_complexity_floor.py`
119
- (`compute_complexity_floor`) and `scripts/dev_tools/resolve_delegation_model.py`
120
- (`resolve_delegation_model`). Default `fable_policy` is `disabled` when the marker is absent.
118
+ reference implementations are `.claude/lib/model-routing/ModelRouting.psm1`
119
+ (`Get-ComplexityFloor`) and `.claude/lib/model-routing/ModelRouting.psm1`
120
+ (`Resolve-DelegationModel`). Default `fable_policy` is `disabled` when the marker is absent.
121
+
122
+ When `epic-orchestrator` itself spawns `Agent(orchestrator)` or `Agent(pr-author)`, it applies
123
+ the same per-delegation resolution and passes `model` equal to the routing receipt's `model` on
124
+ the spawn call. It MUST NOT omit `model` (an omitted `model` falls back to the delegate's
125
+ frontmatter default — `opus` for these workers — which suppresses a `fable` resolution) and
126
+ MUST NOT hard-code `model=opus` in a way that overrides the resolved routing model, mirroring
127
+ step 5 of `## Model Selection` in `.claude/skills/orchestrate/SKILL.md`.
121
128
 
122
129
  `route` is never an input to model selection; `route` remains file-count driven and governs only
123
130
  agents, skills, and MCP tools. A skill whose frontmatter `context` field holds the value `fork`
@@ -29,9 +29,9 @@ On every invocation, the main session must:
29
29
  Because model selection is required once delegation occurs (see `## Model Selection`), a resuming orchestrator must repair a missing model choice deterministically before delegating at a delegating `next_step`. When the resumed `next_step` is a delegating step:
30
30
 
31
31
  a. **Preflight the checkpoint.** Run the orchestrator-state validator with `--require-model-routing` (via `mcp__drm-copilot__validate_orchestration_artifacts` or the local CLI) against `artifacts/orchestration/orchestrator-state.json` before the first delegation. Record the result in a `model_routing_preflight` block `{ status ("pass"|"fail"), checked_at (ISO-8601 UTC), validator_command, output_summary }`.
32
- b. **Recompute the floor.** For the upcoming phase, recompute the complexity floor with `compute_complexity_floor(signals_present)` (`scripts/dev_tools/compute_complexity_floor.py`); do not reimplement the formula.
32
+ b. **Recompute the floor.** For the upcoming phase, recompute the complexity floor with `Get-ComplexityFloor -SignalsPresent <names>` (`.claude/lib/model-routing/ModelRouting.psm1`); do not reimplement the formula.
33
33
  c. **Record the assessment.** Write a `complexity_assessments[]` entry `{ phase, band, floor, signals_present[], rationale, assessed_at }` with `floor` equal to the recomputed value and `band >= floor`.
34
- d. **Resolve and record the receipt.** Resolve the model with `resolve_delegation_model(agent, complexity_band, fable_policy)` (`scripts/dev_tools/resolve_delegation_model.py`) and write a `model_routing_receipts[]` entry `{ agent, phase, complexity_band, fable_policy, table_model, clamped_from | null, model }`.
34
+ d. **Resolve and record the receipt.** Resolve the model with `Resolve-DelegationModel -Agent <agent> -Band <complexity_band> -FablePolicy <fable_policy>` (`.claude/lib/model-routing/ModelRouting.psm1`) and write a `model_routing_receipts[]` entry `{ agent, phase, complexity_band, fable_policy, table_model, clamped_from | null, model }`.
35
35
  e. **Persist and delegate.** Persist the checkpoint, then delegate with `model` equal to the receipt's `model`.
36
36
 
37
37
  The orchestrator MUST NOT delegate at a delegating `next_step` while `model_routing_preflight` status is `fail`; it repairs the missing choice (steps b-e) and re-preflights until the status is `pass`.
@@ -83,8 +83,10 @@ Model selection is a second axis, strictly separate from `route`. `route` (`smal
83
83
 
84
84
  The two canonical, tested reference implementations express the formulas the orchestrator applies by judgment:
85
85
 
86
- - `scripts/dev_tools/compute_complexity_floor.py` (`compute_complexity_floor`) — the deterministic complexity-floor formula. Each present `[floor]` signal contributes a candidate band of `C3`; the floor is the maximum triggered candidate band; the floor never exceeds `C3`. C4 is never floor-forced; it is reached only by judgment.
87
- - `scripts/dev_tools/resolve_delegation_model.py` (`resolve_delegation_model`) — the delegation-model selection formula (base `complexity_to_model` table, the `preferred` overlay, and the `disabled` clamp).
86
+ - `.claude/lib/model-routing/ModelRouting.psm1` (`Get-ComplexityFloor`) — the deterministic complexity-floor formula. Each present `[floor]` signal contributes a candidate band of `C3`; the floor is the maximum triggered candidate band; the floor never exceeds `C3`. C4 is never floor-forced; it is reached only by judgment.
87
+ - `.claude/lib/model-routing/ModelRouting.psm1` (`Resolve-DelegationModel`) — the delegation-model selection formula (base `complexity_to_model` table, the `preferred` overlay, and the `disabled` clamp).
88
+
89
+ The runnable reference the destination runtime applies is the `.claude`-resident PowerShell module above; the repository validator remains the Python authority (`scripts/dev_tools/compute_complexity_floor.py` and `scripts/dev_tools/resolve_delegation_model.py`), pinned to the same `config/orchestration-routing.json` truth table by a static config-parity test.
88
90
 
89
91
  End-to-end procedure:
90
92
 
@@ -93,6 +95,8 @@ End-to-end procedure:
93
95
  3. **Run the per-delegation selection order.** For each delegation, resolve the model as `resolve_delegation_model(agent, complexity_band, fable_policy)`: the `table_model` is the `preferred` overlay value when (`fable_policy == "preferred"` and the agent is in the overlay set `{atomic-planner, prd-feature, feature-review, task-researcher}` and `band == "C3"`), otherwise the base `complexity_to_model[band]`. Under `fable_policy == "disabled"`, a `fable` `table_model` clamps to `model = "opus"` with `clamped_from = "fable"`. `atomic-executor` and `pr-author` C3 cells stay `opus` under every policy.
94
96
  4. **Emit a routing receipt.** Record a `model_routing_receipts[]` entry `{ agent, phase, complexity_band, fable_policy, table_model, clamped_from | null, model }`. `table_model` is the pre-clamp lookup; `model` is the post-clamp result.
95
97
 
98
+ 5. **Delegate with the resolved model.** Pass `model` equal to the receipt's `model` on the `Agent(...)` spawn call for that delegation. The orchestrator MUST NOT omit `model` on the spawn (an omitted `model` falls back to the delegate's frontmatter default — `opus` for most workers — which suppresses a `fable` or `sonnet` resolution), and MUST NOT hard-code `model=opus` in a way that overrides the resolved routing model. This applies to every fresh delegation, mirroring the resume-path rule ("delegate with `model` equal to the receipt's `model`") so both paths bind the spawn model to the routing receipt.
99
+
96
100
  The `complexity_assessments[]` and `model_routing_receipts[]` invariants are enforced by `scripts/dev_tools/validate_orchestrator_state.py` per `.claude/rules/orchestrator-state.md`; both arrays remain additive (a checkpoint that predates model routing stays valid).
97
101
 
98
102
  ### Required-once-delegated invariant (`require_model_routing` mode)
@@ -69,6 +69,9 @@
69
69
  ".claude/skills/review-staged/SKILL.md",
70
70
  ".claude/skills/skill-canonical-location-audit/SKILL.md",
71
71
  ".claude/skills/translate-copilot-to-claude/SKILL.md",
72
- ".claude/skills/update-status/SKILL.md"
72
+ ".claude/skills/update-status/SKILL.md",
73
+ ".claude/lib/model-routing/ModelRouting.psm1",
74
+ ".claude/lib/orchestrator-state/OrchestratorState.psm1",
75
+ ".claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1"
73
76
  ]
74
77
  }
@@ -163,6 +163,6 @@
163
163
  },
164
164
  "model_budget": {
165
165
  "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).",
166
- "fable_policy": "disabled"
166
+ "fable_policy": "preferred"
167
167
  }
168
168
  }