@danmoisan/drm-copilot-mcp 1.0.9 → 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.9",
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,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
@@ -119,6 +119,13 @@ reference implementations are `.claude/lib/model-routing/ModelRouting.psm1`
119
119
  (`Get-ComplexityFloor`) and `.claude/lib/model-routing/ModelRouting.psm1`
120
120
  (`Resolve-DelegationModel`). Default `fable_policy` is `disabled` when the marker is absent.
121
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`.
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`
124
131
  inherits the parent model and ignores a model override, so model selection applies to agent
@@ -95,6 +95,8 @@ End-to-end procedure:
95
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.
96
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.
97
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
+
98
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).
99
101
 
100
102
  ### Required-once-delegated invariant (`require_model_routing` mode)
@@ -70,6 +70,8 @@
70
70
  ".claude/skills/skill-canonical-location-audit/SKILL.md",
71
71
  ".claude/skills/translate-copilot-to-claude/SKILL.md",
72
72
  ".claude/skills/update-status/SKILL.md",
73
- ".claude/lib/model-routing/ModelRouting.psm1"
73
+ ".claude/lib/model-routing/ModelRouting.psm1",
74
+ ".claude/lib/orchestrator-state/OrchestratorState.psm1",
75
+ ".claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1"
74
76
  ]
75
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
  }