@danmoisan/drm-copilot-mcp 1.0.7 → 1.0.8

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.
Files changed (27) hide show
  1. package/out/mcp-server.js +82 -8
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/atomic-executor.md +1 -0
  4. package/resources/claude-customizations/.claude/agents/atomic-planner.md +1 -0
  5. package/resources/claude-customizations/.claude/agents/csharp-typed-engineer.md +1 -0
  6. package/resources/claude-customizations/.claude/agents/epic-orchestrator.md +1 -0
  7. package/resources/claude-customizations/.claude/agents/epic-review.md +1 -0
  8. package/resources/claude-customizations/.claude/agents/feature-review.md +1 -0
  9. package/resources/claude-customizations/.claude/agents/orchestrator.md +13 -0
  10. package/resources/claude-customizations/.claude/agents/powershell-typed-engineer.md +1 -0
  11. package/resources/claude-customizations/.claude/agents/prd-feature.md +1 -0
  12. package/resources/claude-customizations/.claude/agents/python-typed-engineer.md +1 -0
  13. package/resources/claude-customizations/.claude/agents/staged-review.md +1 -0
  14. package/resources/claude-customizations/.claude/agents/status-updater.md +1 -0
  15. package/resources/claude-customizations/.claude/agents/typescript-engineer.md +1 -0
  16. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +182 -0
  17. package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +9 -1
  18. package/resources/claude-customizations/.claude/rules/orchestrator-state.md +17 -0
  19. package/resources/claude-customizations/.claude/settings.json +4 -0
  20. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +18 -1
  21. package/resources/claude-customizations/pack-manifests/core.json +1 -0
  22. package/resources/codex-and-agents-customizations/.agents/skills/feature-promotion-lifecycle/SKILL.md +8 -2
  23. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +18 -0
  24. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-workflow/SKILL.md +13 -0
  25. package/resources/codex-and-agents-customizations/.codex/agents/orchestrator.toml +15 -14
  26. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-completion-consistency.ps1 +144 -28
  27. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-completion-helpers.ps1 +163 -0
package/out/mcp-server.js CHANGED
@@ -16208,6 +16208,10 @@ var REPO_AUTOMATION_TOOL_DEFINITIONS = [
16208
16208
  require_complete: {
16209
16209
  type: "boolean",
16210
16210
  description: "When true and artifact_type is 'orchestrator-state', require all phases to be complete."
16211
+ },
16212
+ require_model_routing: {
16213
+ type: "boolean",
16214
+ description: "When true and artifact_type is 'orchestrator-state', require a model_routing_receipts entry per delegated agent once a delegation is recorded. The TypeScript side performs the existence check only; the Python validator is authoritative for full per-receipt correctness."
16211
16215
  }
16212
16216
  },
16213
16217
  required: ["artifact_type", "artifact_path"],
@@ -16597,6 +16601,7 @@ function resolveValidateOrchestrationArtifactsToolInput(rawInput, fallbackWorksp
16597
16601
  );
16598
16602
  }
16599
16603
  const requireComplete = args["require_complete"];
16604
+ const requireModelRouting = args["require_model_routing"];
16600
16605
  return {
16601
16606
  workspaceRoot: normalizeWorkspaceRoot(
16602
16607
  args["workspace_root"],
@@ -16604,7 +16609,8 @@ function resolveValidateOrchestrationArtifactsToolInput(rawInput, fallbackWorksp
16604
16609
  ),
16605
16610
  artifactType,
16606
16611
  artifactPath: normalizeRequiredText(args["artifact_path"], "artifact_path"),
16607
- ...requireComplete === true ? { requireComplete: true } : {}
16612
+ ...requireComplete === true ? { requireComplete: true } : {},
16613
+ ...requireModelRouting === true ? { requireModelRouting: true } : {}
16608
16614
  };
16609
16615
  }
16610
16616
 
@@ -22519,6 +22525,14 @@ var STEP_STATUS_KEYS = [
22519
22525
  "step9_status",
22520
22526
  "step10_status"
22521
22527
  ];
22528
+ var DELEGATING_AGENTS = /* @__PURE__ */ new Set([
22529
+ "atomic-planner",
22530
+ "atomic-executor",
22531
+ "feature-review",
22532
+ "task-researcher",
22533
+ "prd-feature",
22534
+ "pr-author"
22535
+ ]);
22522
22536
  function isObject7(value) {
22523
22537
  return typeof value === "object" && value !== null && !Array.isArray(value);
22524
22538
  }
@@ -22582,6 +22596,53 @@ function resolveRoutingMatrix(options) {
22582
22596
  }
22583
22597
  return void 0;
22584
22598
  }
22599
+ function delegatedAgents(stateMap) {
22600
+ const agents = /* @__PURE__ */ new Set();
22601
+ const receipts = stateMap["delegation_receipts"];
22602
+ if (Array.isArray(receipts)) {
22603
+ receipts.forEach((receipt) => {
22604
+ if (!isObject7(receipt)) {
22605
+ return;
22606
+ }
22607
+ const agentName = receipt["agent_name"];
22608
+ if (typeof agentName === "string" && agentName.trim() !== "") {
22609
+ agents.add(agentName);
22610
+ }
22611
+ });
22612
+ }
22613
+ const nextStep = stateMap["next_step"];
22614
+ if (typeof nextStep === "string" && DELEGATING_AGENTS.has(nextStep)) {
22615
+ agents.add(nextStep);
22616
+ }
22617
+ return agents;
22618
+ }
22619
+ function validateModelRoutingExistence(stateMap) {
22620
+ const errors = [];
22621
+ const delegated = delegatedAgents(stateMap);
22622
+ if (delegated.size === 0) {
22623
+ return errors;
22624
+ }
22625
+ const receiptAgents2 = /* @__PURE__ */ new Set();
22626
+ const receipts = stateMap["model_routing_receipts"];
22627
+ if (Array.isArray(receipts)) {
22628
+ receipts.forEach((receipt) => {
22629
+ if (!isObject7(receipt)) {
22630
+ return;
22631
+ }
22632
+ const agent = receipt["agent"];
22633
+ if (typeof agent === "string" && agent.trim() !== "") {
22634
+ receiptAgents2.add(agent);
22635
+ }
22636
+ });
22637
+ }
22638
+ const missing = [...delegated].filter((a) => !receiptAgents2.has(a)).sort();
22639
+ for (const agent of missing) {
22640
+ errors.push(
22641
+ `Checkpoint model_routing_receipts is missing a receipt for delegated agent: ${agent}.`
22642
+ );
22643
+ }
22644
+ return errors;
22645
+ }
22585
22646
  function validateOrchestratorStateText(text, options = {}) {
22586
22647
  const errors = [];
22587
22648
  let state;
@@ -22655,6 +22716,9 @@ function validateOrchestratorStateText(text, options = {}) {
22655
22716
  })
22656
22717
  );
22657
22718
  }
22719
+ if (options.requireModelRouting === true) {
22720
+ errors.push(...validateModelRoutingExistence(stateMap));
22721
+ }
22658
22722
  return errors;
22659
22723
  }
22660
22724
 
@@ -23029,6 +23093,7 @@ function validateArtifact(input) {
23029
23093
  case "orchestrator-state": {
23030
23094
  const options = {
23031
23095
  ...input.requireComplete === void 0 ? {} : { requireComplete: input.requireComplete },
23096
+ ...input.requireModelRouting === void 0 ? {} : { requireModelRouting: input.requireModelRouting },
23032
23097
  ...input.fs === void 0 ? {} : { fs: input.fs },
23033
23098
  ...input.root === void 0 ? {} : { root: input.root },
23034
23099
  ...input.routingMatrix === void 0 ? {} : { routingMatrix: input.routingMatrix }
@@ -23056,6 +23121,7 @@ function validateOrchestrationServiceCall(input) {
23056
23121
  artifactType: input.artifactType,
23057
23122
  text,
23058
23123
  ...input.requireComplete === void 0 ? {} : { requireComplete: input.requireComplete },
23124
+ ...input.requireModelRouting === void 0 ? {} : { requireModelRouting: input.requireModelRouting },
23059
23125
  fs: input.fileSystem,
23060
23126
  root: input.workspaceRoot
23061
23127
  });
@@ -23072,6 +23138,18 @@ ${errors.join("\n")}`
23072
23138
  };
23073
23139
  }
23074
23140
 
23141
+ // ../../extensions/drm-copilot/src/lib/validate/build-validate-orchestration-service-call-input.ts
23142
+ function buildValidateOrchestrationServiceCallInput(fileSystem, input) {
23143
+ return {
23144
+ fileSystem,
23145
+ workspaceRoot: input.workspaceRoot,
23146
+ artifactType: input.artifactType,
23147
+ artifactPath: input.artifactPath,
23148
+ ...input.requireComplete === void 0 ? {} : { requireComplete: input.requireComplete },
23149
+ ...input.requireModelRouting === void 0 ? {} : { requireModelRouting: input.requireModelRouting }
23150
+ };
23151
+ }
23152
+
23075
23153
  // ../../extensions/drm-copilot/src/lib/new-potential-bug-entry.ts
23076
23154
  var fs5 = __toESM(require("node:fs"));
23077
23155
  var nodePath4 = __toESM(require("node:path"));
@@ -27713,13 +27791,9 @@ var DefaultRepoAutomationService = class {
27713
27791
  });
27714
27792
  }
27715
27793
  async validateOrchestrationArtifacts(input) {
27716
- return validateOrchestrationServiceCall({
27717
- fileSystem: this.fileSystem,
27718
- workspaceRoot: input.workspaceRoot,
27719
- artifactType: input.artifactType,
27720
- artifactPath: input.artifactPath,
27721
- ...input.requireComplete === void 0 ? {} : { requireComplete: input.requireComplete }
27722
- });
27794
+ return validateOrchestrationServiceCall(
27795
+ buildValidateOrchestrationServiceCallInput(this.fileSystem, input)
27796
+ );
27723
27797
  }
27724
27798
  async executeScript(options) {
27725
27799
  const execution = await executeBundledScriptFromExtensionRoot(this.output, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmoisan/drm-copilot-mcp",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Stdio MCP server exposing drm-copilot repo-automation tools.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: atomic-executor
3
+ model: opus
3
4
  description: Plan execution agent that runs approved atomic plans task-by-task with explicit toolchain commands for Python, TypeScript, PowerShell, and C# quality gates.
4
5
  tools:
5
6
  - Read
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: atomic-planner
3
+ model: opus
3
4
  description: Planning-only agent that generates deterministic phased implementation plans with atomic P#-T# checkbox tasks, writing output to docs/ and artifacts/ paths only.
4
5
  tools:
5
6
  - Read
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: csharp-typed-engineer
3
+ model: sonnet
3
4
  description: Project-scoped worker that implements and verifies C# changes within typed repository boundaries. Applies the CSharpier -> .NET Analyzers -> Nullable Analysis -> xUnit toolchain, the 1-3 production-file small-path budget, and zero-regression quality gates.
4
5
  tools:
5
6
  - Read
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: epic-orchestrator
3
+ model: opus
3
4
  description: Deterministic epic-scale orchestrator that schedules a dependency graph of child features across parallel, isolated git worktrees, fans results back into a shared integration branch, and drives the final integration-to-main PR. Distinct from orchestrator; only this agent is authorized to delegate to Agent(orchestrator).
4
5
  tools:
5
6
  - "Agent(orchestrator)"
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: epic-review
3
+ model: opus
3
4
  description: Project-scoped worker that reviews epic folders and writes epic-audit artifacts.
4
5
  tools:
5
6
  - Read
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: feature-review
3
+ model: opus
3
4
  description: Feature branch review specialist that produces policy-audit, code-review, and feature-audit artifacts restricted to docs/features/active/ write path.
4
5
  tools:
5
6
  - Read
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: orchestrator
3
+ model: opus
3
4
  description: Deterministic repository orchestrator that estimates change budget, selects small or large workflow path, delegates to specialist subagents, persists checkpoint state, and enforces completion gates proactively.
4
5
  tools:
5
6
  - "Agent(atomic-planner,atomic-executor,feature-review,task-researcher,prd-feature,staged-review,epic-review,status-updater,pr-author,commit-message,human-exception-runbook,python-typed-engineer,powershell-typed-engineer,csharp-typed-engineer,typescript-engineer)"
@@ -56,6 +57,18 @@ On every invocation:
56
57
  4. If a valid checkpoint exists with a matching objective, resume from the recorded `next_step`.
57
58
  5. If no checkpoint exists or the objective is new, begin from change-budget estimation.
58
59
 
60
+ ### Model-choice reconciliation on resume
61
+
62
+ When the resumed `next_step` is a delegating step, repair a missing model choice deterministically before delegating (this mirrors `## Checkpoint Handling` in `.claude/skills/orchestrate/SKILL.md`):
63
+
64
+ a. Run the orchestrator-state validator with `--require-model-routing` before the first delegation and record a `model_routing_preflight` block `{ status ("pass"|"fail"), checked_at, validator_command, output_summary }`.
65
+ b. Recompute the upcoming phase's floor with `compute_complexity_floor(signals_present)` (no reimplementation).
66
+ c. Record a `complexity_assessments[]` entry `{ phase, band, floor, signals_present[], rationale, assessed_at }` with `floor` equal to the recomputed value and `band >= floor`.
67
+ d. Resolve the model with `resolve_delegation_model(agent, complexity_band, fable_policy)` and record a `model_routing_receipts[]` entry `{ agent, phase, complexity_band, fable_policy, table_model, clamped_from | null, model }`.
68
+ e. Persist the checkpoint, then delegate with `model` equal to the receipt's `model`.
69
+
70
+ 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`.
71
+
59
72
  ## Change Budget Routing
60
73
 
61
74
  The first action is always to estimate the change budget by identifying likely affected production files and tests:
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: powershell-typed-engineer
3
+ model: sonnet
3
4
  description: Project-scoped worker that implements and verifies PowerShell changes within typed repository boundaries. Applies PoshQC format -> PSScriptAnalyzer -> Pester toolchain, the 1-2 production-file direct-mode budget, the 3-production + 3-test per-batch cap, and zero-regression quality gates.
4
5
  tools:
5
6
  - Read
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: prd-feature
3
+ model: opus
3
4
  description: Project-scoped worker that produces feature-document outputs from issue and research context.
4
5
  tools:
5
6
  - Read
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: python-typed-engineer
3
+ model: sonnet
3
4
  description: Project-scoped worker that implements and verifies Python changes within typed repository boundaries. Applies the Black -> Ruff -> Pyright -> Pytest toolchain, the 3-production + 3-test per-batch budget, and zero-regression quality gates.
4
5
  tools:
5
6
  - Read
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: staged-review
3
+ model: opus
3
4
  description: Project-scoped worker that reviews staged diffs and writes staged-review artifacts.
4
5
  tools:
5
6
  - Read
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: status-updater
3
+ model: haiku
3
4
  description: Project-scoped worker that reconciles plan and issue status and writes status-sync artifacts.
4
5
  tools:
5
6
  - Read
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: typescript-engineer
3
+ model: sonnet
3
4
  description: Project-scoped worker that implements and verifies TypeScript changes within typed repository boundaries.
4
5
  tools:
5
6
  - Read
@@ -0,0 +1,182 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Pre-tool-use hook that blocks a delegation to a gated subagent when the
4
+ orchestrator checkpoint records no model-routing receipt for that agent.
5
+
6
+ .DESCRIPTION
7
+ Invoked by the Claude Code PreToolUse hook on the Agent (Task) tool. Reads
8
+ tool input JSON from the CLAUDE_TOOL_INPUT environment variable and the
9
+ orchestrator checkpoint from artifacts/orchestration/orchestrator-state.json.
10
+
11
+ The hook enforces presence only: it cannot read the delegate's chosen
12
+ `model` (no `model` field is exposed in the tool input), so it verifies that
13
+ a `model_routing_receipts[]` entry already exists for the target
14
+ `subagent_type`. Correctness of the recorded model stays with the
15
+ authoritative Python validator.
16
+
17
+ Gated subagent types are the Agent-tool delegates that participate in model
18
+ selection: atomic-planner, atomic-executor, feature-review, task-researcher,
19
+ prd-feature, pr-author. The `orchestrator` type is deliberately excluded: it
20
+ is the calling agent, not a subagent delegated via the Agent tool, so it is
21
+ never a receipt-gated `subagent_type`.
22
+
23
+ Allow-through (graceful allow) applies to a non-delegating `subagent_type`,
24
+ empty or absent tool input, and malformed tool-input JSON.
25
+
26
+ The checkpoint read goes through Get-ModelRoutingCheckpoint so tests can
27
+ inject a synthetic checkpoint without touching disk.
28
+
29
+ .NOTES
30
+ Compatible with PowerShell 7+. Read-only presence-gating deterrent.
31
+ #>
32
+ [CmdletBinding()]
33
+ param()
34
+
35
+ function Get-ModelRoutingCheckpoint {
36
+ <#
37
+ .SYNOPSIS
38
+ Returns the parsed orchestrator checkpoint object, or $null when the
39
+ file is missing or not valid JSON. Tests mock this seam.
40
+ #>
41
+ [CmdletBinding()]
42
+ [OutputType([object])]
43
+ param(
44
+ [string] $CheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
45
+ )
46
+
47
+ if (-not (Test-Path -LiteralPath $CheckpointPath -PathType Leaf)) {
48
+ return $null
49
+ }
50
+
51
+ try {
52
+ $raw = Get-Content -LiteralPath $CheckpointPath -Raw -ErrorAction Stop
53
+ return $raw | ConvertFrom-Json -ErrorAction Stop
54
+ }
55
+ catch {
56
+ return $null
57
+ }
58
+ }
59
+
60
+ function Get-ModelRoutingGatedAgent {
61
+ <#
62
+ .SYNOPSIS
63
+ Returns the set of subagent types that are receipt-gated: the Agent-tool
64
+ delegates that participate in model selection. `orchestrator` is
65
+ excluded because it is the caller, not a delegated subagent.
66
+ #>
67
+ [CmdletBinding()]
68
+ [OutputType([string[]])]
69
+ param()
70
+
71
+ return [string[]] @(
72
+ 'atomic-planner',
73
+ 'atomic-executor',
74
+ 'feature-review',
75
+ 'task-researcher',
76
+ 'prd-feature',
77
+ 'pr-author'
78
+ )
79
+ }
80
+
81
+ function Test-ModelRoutingReceiptPresent {
82
+ <#
83
+ .SYNOPSIS
84
+ Returns $true when the checkpoint carries a model_routing_receipts entry
85
+ whose agent equals the target subagent type.
86
+ #>
87
+ [CmdletBinding()]
88
+ [OutputType([bool])]
89
+ param(
90
+ [Parameter(Mandatory)]
91
+ [AllowNull()]
92
+ $Checkpoint,
93
+
94
+ [Parameter(Mandatory)]
95
+ [string] $Subagent
96
+ )
97
+
98
+ if ($null -eq $Checkpoint) {
99
+ return $false
100
+ }
101
+ if ($Checkpoint.PSObject.Properties.Name -notcontains 'model_routing_receipts') {
102
+ return $false
103
+ }
104
+
105
+ # Scan every receipt for one whose agent matches the delegated subagent type.
106
+ foreach ($receipt in @($Checkpoint.model_routing_receipts)) {
107
+ if ($null -eq $receipt) {
108
+ continue
109
+ }
110
+ if ($receipt.PSObject.Properties.Name -contains 'agent' -and
111
+ [string]$receipt.agent -eq $Subagent) {
112
+ return $true
113
+ }
114
+ }
115
+ return $false
116
+ }
117
+
118
+ function Invoke-ModelRoutingReceiptDecision {
119
+ <#
120
+ .SYNOPSIS
121
+ Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision object.
122
+ #>
123
+ [CmdletBinding()]
124
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
125
+ param(
126
+ [string] $ToolInputRaw
127
+ )
128
+
129
+ $allow = [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
130
+
131
+ # Empty or absent tool input is not a delegation this hook can gate.
132
+ if (-not $ToolInputRaw) {
133
+ return $allow
134
+ }
135
+
136
+ # Malformed tool-input JSON is allowed through gracefully; this hook is a
137
+ # deterrent, not the authoritative validator.
138
+ try {
139
+ $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
140
+ }
141
+ catch {
142
+ return $allow
143
+ }
144
+
145
+ $subagent = [string]$toolInput.subagent_type
146
+
147
+ # Only the gated Agent-tool delegates are receipt-checked; any other
148
+ # subagent_type (including orchestrator) passes through.
149
+ if (-not $subagent -or ((Get-ModelRoutingGatedAgent) -notcontains $subagent)) {
150
+ return $allow
151
+ }
152
+
153
+ $checkpoint = Get-ModelRoutingCheckpoint
154
+ if (Test-ModelRoutingReceiptPresent -Checkpoint $checkpoint -Subagent $subagent) {
155
+ return $allow
156
+ }
157
+
158
+ return [ordered]@{
159
+ hookSpecificOutput = [ordered]@{
160
+ hookEventName = 'PreToolUse'
161
+ permissionDecision = 'deny'
162
+ permissionDecisionReason = "MODEL_ROUTING_RECEIPT_BLOCKED: cannot delegate to '$subagent' before a model_routing_receipts entry for it is recorded in the orchestrator checkpoint. Perform Model Selection (record the complexity assessment and routing receipt) before delegating."
163
+ }
164
+ }
165
+ }
166
+
167
+ # Guard allows dot-sourcing in tests without executing the entrypoint.
168
+ if ($MyInvocation.InvocationName -eq '.') {
169
+ return
170
+ }
171
+
172
+ try {
173
+ $decision = Invoke-ModelRoutingReceiptDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
174
+ }
175
+ catch {
176
+ Write-Error $_
177
+ exit 1
178
+ }
179
+
180
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
181
+
182
+ exit 0
@@ -180,7 +180,7 @@ function Invoke-RoutingContractValidation {
180
180
  [scriptblock] $Invoker = {
181
181
  param($Path, $Type)
182
182
  $output = & python -m scripts.dev_tools.validate_orchestration_artifacts `
183
- $Type $Path --require-complete 2>&1
183
+ $Type $Path --require-complete --require-model-routing 2>&1
184
184
  [pscustomobject]@{
185
185
  ExitCode = $LASTEXITCODE
186
186
  Output = ($output | Out-String)
@@ -293,6 +293,14 @@ function Invoke-OrchestratorOutputValidation {
293
293
  }
294
294
  $routingResult = Invoke-RoutingContractValidation @routingArgs
295
295
  if ($routingResult.HasErrors) {
296
+ # One subprocess call now covers both --require-complete and
297
+ # --require-model-routing. Surface a model-routing gate failure under its
298
+ # own block reason (its errors name model_routing_receipts or
299
+ # complexity_assessments); otherwise fall back to the routing-contract
300
+ # block reason for a generic completion/routing failure.
301
+ if ($routingResult.ErrorText -match 'model_routing_receipts|complexity_assessments') {
302
+ return @{ Ok = $false; Message = "MODEL_ROUTING_BLOCKED: $($routingResult.ErrorText)" }
303
+ }
296
304
  return @{ Ok = $false; Message = "ROUTING_CONTRACT_BLOCKED: $($routingResult.ErrorText)" }
297
305
  }
298
306
 
@@ -66,10 +66,27 @@ Each entry records one delegation with the shape `{ agent, phase, complexity_ban
66
66
 
67
67
  The session `model_budget.fable_policy` switch is a three-way enum `disabled | available | preferred` defined in `config/orchestration-routing.json`, defaulting to `disabled`. It governs only the delegation model tier and is not a route input. `disabled` removes `fable` from the consideration set and clamps `fable` cells to `opus`; `available` applies the base `complexity_to_model` table as-is; `preferred` applies the `preferred_overlay` (which redirects only the C3 cell to `fable` for the overlay agents `atomic-planner`, `prd-feature`, `feature-review`, `task-researcher`) and leaves `atomic-executor` and `pr-author` C3 cells at `opus`. `route` is never an input to model selection.
68
68
 
69
+ ## Require-Model-Routing Mode Scope and Backward Compatibility
70
+
71
+ The complexity-assessment and model-routing-receipt invariants above are key-gated: they run only when their key is present, so a checkpoint that omits both arrays passes at every stage. The `require_model_routing` mode adds an existence gate that closes that gap without changing the default behavior. It is an opt-in keyword on `validate_orchestrator_state_text(..., require_model_routing=False)` (CLI flag `--require-model-routing`; MCP parameter `require_model_routing`), defaulting off. Plain, `require_complete`, and `require_pr_creation_ready` calls are unaffected and produce byte-identical results.
72
+
73
+ ## Invariants (require_model_routing mode)
74
+
75
+ These invariants apply only when a caller passes `require_model_routing=True` and the checkpoint records at least one delegation. A checkpoint with zero delegations (no well-formed `delegation_receipts[]` entry and a `next_step` that names no delegating agent) imposes no requirement, so genuinely old, delegation-free checkpoints stay valid.
76
+
77
+ 1. **Required routing receipt once delegated.** Once the checkpoint records a delegation, the set of `model_routing_receipts[].agent` must be a superset of the delegated-agent set (each well-formed `delegation_receipts[].agent_name` plus a `next_step` that names a delegating agent). A delegated agent with no matching receipt is a violation. The delegating agent set excludes `orchestrator` (the caller, not a delegated subagent).
78
+
79
+ 2. **Required complexity assessment per matched phase.** Each phase named by a routing receipt whose agent is in the delegated-agent set must have a `complexity_assessments[]` entry for that phase.
80
+
81
+ 3. **Per-entry consistency reused, not reimplemented.** Present receipts and assessments must satisfy the model-routing-receipt and complexity-assessment invariants above; the gate reuses `_validate_model_routing_receipts` and `_validate_complexity_assessments` and never reimplements `compute_complexity_floor` or `resolve_delegation_model`. The gate logic lives in `scripts/dev_tools/_orchestrator_state_model_routing_gate.py`; enforcement is the Python validator, not an imported schema.
82
+
83
+ The completion hook (`.claude/hooks/validate-orchestrator-output.ps1`) passes `--require-model-routing` alongside `--require-complete` and surfaces a gate failure as the `MODEL_ROUTING_BLOCKED:` block reason. The PreToolUse deterrent (`.claude/hooks/enforce-model-routing-receipt.ps1`) performs presence-only gating before a delegation. The MCP TypeScript surface performs the existence check only (delegated-agent set ⊆ routing-receipt-agent set); the Python validator remains authoritative for per-receipt correctness.
84
+
69
85
  ## Enforcement
70
86
 
71
87
  - `scripts/dev_tools/validate_orchestrator_state.py` appends one error per violated invariant when a `remediation_loop` is present, using the existing validator message style (literal, checkpoint-context prefixed). The validator returns a list of error strings and does not mutate its input.
72
88
  - `scripts/dev_tools/validate_orchestrator_state.py` likewise appends one error per violated `human_interaction` invariant when a `human_interaction` key is present, using the same literal, checkpoint-context-prefixed message style. The check does not import or read any schema file.
73
89
  - `scripts/dev_tools/validate_orchestrator_state.py` appends one error per violated `complexity_assessments` invariant when a `complexity_assessments` key is present, delegating to `scripts/dev_tools/_orchestrator_state_complexity.py`, which recomputes the floor via `compute_complexity_floor`. The check does not import or read any schema file.
74
90
  - `scripts/dev_tools/validate_orchestrator_state.py` appends one error per violated `model_routing_receipts` invariant when a `model_routing_receipts` key is present, delegating to `scripts/dev_tools/_orchestrator_state_model_routing.py`, which recomputes the resolved model via `resolve_delegation_model`. The check does not import or read any schema file.
91
+ - `scripts/dev_tools/validate_orchestrator_state.py` appends one error per violated `require_model_routing` invariant only when the caller passes `require_model_routing=True`, delegating to `scripts/dev_tools/_orchestrator_state_model_routing_gate.py`, which reuses the complexity and model-routing per-entry validators. When the flag is not passed the gate does not run, so existing calls are byte-identical.
75
92
  - The validator is consumed by the MCP tool `validate_orchestration_artifacts`; backward compatibility for existing step-based checkpoints is preserved.
@@ -156,6 +156,10 @@
156
156
  {
157
157
  "type": "command",
158
158
  "command": "pwsh -NoProfile -File .claude/hooks/enforce-epic-wave-barrier.ps1"
159
+ },
160
+ {
161
+ "type": "command",
162
+ "command": "pwsh -NoProfile -File .claude/hooks/enforce-model-routing-receipt.ps1"
159
163
  }
160
164
  ]
161
165
  }
@@ -24,6 +24,18 @@ On every invocation, the main session must:
24
24
  2. If a valid checkpoint exists with a matching objective, resume from the recorded `next_step`.
25
25
  3. If no checkpoint exists or the objective is new, begin the orchestration lifecycle from the start.
26
26
 
27
+ ### Model-choice reconciliation on resume
28
+
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
+
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.
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 }`.
35
+ e. **Persist and delegate.** Persist the checkpoint, then delegate with `model` equal to the receipt's `model`.
36
+
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`.
38
+
27
39
  ## Autonomous-Execution Mandate
28
40
 
29
41
  The orchestrator must achieve all actions agentically with no human interaction; full autonomy is a hard requirement. A silent manual blocker discovered at the end of a workflow is a defect, not an acceptable outcome. Every unautomatable (human-interaction) requirement must be detected early, resolved by exactly one of three permitted responses, and recorded in orchestrator state.
@@ -81,7 +93,11 @@ End-to-end procedure:
81
93
  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.
82
94
  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.
83
95
 
84
- 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 are additive and optional.
96
+ 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
+
98
+ ### Required-once-delegated invariant (`require_model_routing` mode)
99
+
100
+ The `validate_orchestrator_state_text(...)` validator accepts a `require_model_routing` mode (CLI flag `--require-model-routing`; MCP parameter `require_model_routing`). Under this mode the arrays stop being merely optional: once the checkpoint records at least one delegation (a well-formed `delegation_receipts[]` entry, or a `next_step` that names a delegating agent), every delegated agent must have a matching `model_routing_receipts[]` entry, each matched receipt's phase must have a `complexity_assessments[]` entry, and every present receipt/assessment must be consistent with the reference formulas. A delegation-free checkpoint imposes no requirement, so old checkpoints stay valid. The gate is implemented in `scripts/dev_tools/_orchestrator_state_model_routing_gate.py`; it reuses the per-entry validators and never reimplements `compute_complexity_floor` or `resolve_delegation_model`. Two enforcement layers consume it: the completion gate (`.claude/hooks/validate-orchestrator-output.ps1` passes `--require-model-routing` and surfaces failures as `MODEL_ROUTING_BLOCKED:`), and the pre-delegation deterrent (`.claude/hooks/enforce-model-routing-receipt.ps1`, presence-only). The MCP TypeScript surface performs the existence check only (delegated-agent set ⊆ routing-receipt-agent set); the Python validator is authoritative for per-receipt correctness.
85
101
 
86
102
  **`fork` caveat.** A skill whose frontmatter `context` field holds the value `fork` inherits the parent model and ignores a model override. Model selection therefore applies to agent delegations, not to fork-routed skill invocations.
87
103
 
@@ -126,6 +142,7 @@ The orchestrator must not report completion until:
126
142
  1. All required artifacts for the selected workflow path are present on disk.
127
143
  2. All validation gates (toolchain, acceptance criteria, audit artifacts) have passed.
128
144
  3. The checkpoint file at `artifacts/orchestration/orchestrator-state.json` reflects the completed state.
145
+ 4. The model-routing gate passes: `.claude/hooks/validate-orchestrator-output.ps1` runs the validator with `--require-model-routing` alongside `--require-complete` and refuses DONE with `MODEL_ROUTING_BLOCKED:` when a recorded delegation lacks a matching `model_routing_receipts[]` / `complexity_assessments[]` entry (see the required-once-delegated invariant under `## Model Selection`).
129
146
 
130
147
  ## Pre-Feature-Review Commit
131
148
 
@@ -22,6 +22,7 @@
22
22
  ".claude/hooks/enforce-epic-worktree-removal-gate.ps1",
23
23
  ".claude/hooks/enforce-evidence-locations.ps1",
24
24
  ".claude/hooks/enforce-feature-folder-order.ps1",
25
+ ".claude/hooks/enforce-model-routing-receipt.ps1",
25
26
  ".claude/hooks/enforce-orchestration-preimplementation-gate.ps1",
26
27
  ".claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1",
27
28
  ".claude/hooks/enforce-pr-author-skill.ps1",
@@ -101,8 +101,14 @@ Lifecycle guardrails:
101
101
  - `DIRECTIVE: MINIMAL-AUDIT PLAN REQUIRED`
102
102
 
103
103
  8a) Resolve and persist `${plan-path}` before delegation:
104
- - reuse the earliest existing `plan*.md` in `${feature-folder}` when present
105
- - otherwise create exactly one canonical plan file path and reuse it for all revisions
104
+ - enumerate `${feature-folder}/plan*.md` files in deterministic filename order
105
+ - reuse the first existing `plan*.md` in `${feature-folder}` when present
106
+ - otherwise create exactly one canonical plan file path using the repository's
107
+ feature-folder plan naming convention and reuse it for all revisions
108
+ - never default to `${feature-folder}/plan.md` when a timestamped scaffolded
109
+ plan already exists
110
+ - if checkpoint state already contains a different `${plan-path}`, correct the
111
+ checkpoint before planner delegation instead of creating another plan file
106
112
 
107
113
  9) Require preflight validation via `atomic_executor` until:
108
114
  - `PREFLIGHT: ALL CLEAR`
@@ -109,6 +109,24 @@ staging, commits, or implementation delegation. This gate covers edits, formatte
109
109
  If any required item is missing, implementation is blocked until the checkpoint
110
110
  and lifecycle state are corrected.
111
111
 
112
+ ## Plan-Path Resolution Gate
113
+
114
+ After active feature folder creation and before any planning delegation, the
115
+ main session must resolve `${plan-path}` from the active feature folder:
116
+
117
+ 1. Enumerate existing `${feature-folder}/plan*.md` files in deterministic
118
+ filename order.
119
+ 2. If one or more files exist, persist `${plan-path}` as the first existing
120
+ file and require every planner and executor handoff to use that exact path.
121
+ 3. If no `plan*.md` file exists, create exactly one canonical target path using
122
+ the repository's feature-folder plan naming convention, persist that path,
123
+ and reuse it for all revisions.
124
+ 4. Do not default to `${feature-folder}/plan.md` when a timestamped scaffolded
125
+ plan already exists.
126
+ 5. If checkpoint state names a different plan path than the resolved existing
127
+ plan file, correct the checkpoint before planner delegation. Do not create a
128
+ second plan artifact to satisfy an incorrect checkpoint value.
129
+
112
130
  ## Pre-Implementation Violation Handling
113
131
 
114
132
  If an implementation action is attempted before a required orchestration gate
@@ -101,6 +101,16 @@ Persist and reuse these fields exactly:
101
101
  - `lifecycle_operations`
102
102
  - `pre-implementation-violation`
103
103
 
104
+ Plan-path invariant:
105
+ - `${plan-path}` is resolved only after `${feature-folder}` exists.
106
+ - The orchestrator MUST enumerate existing `${feature-folder}/plan*.md` files
107
+ in deterministic filename order before planner delegation.
108
+ - If any existing plan file is present, `${plan-path}` MUST be that first
109
+ existing file. Do not persist or delegate against `${feature-folder}/plan.md`
110
+ when a timestamped scaffolded plan already exists.
111
+ - If checkpoint state conflicts with the resolved existing plan file, correct
112
+ checkpoint state before delegation and do not create a duplicate plan.
113
+
104
114
  For small-path runs, also persist:
105
115
  - `bootstrap_mode`
106
116
  - `phase0_execution_summary`
@@ -310,6 +320,9 @@ Required behavior:
310
320
  7. When those specialists are not yet migrated, perform the authoring steps directly without changing template headings.
311
321
  8. Spawn `atomic-planner` to finalize `${plan-path}` and require `PREFLIGHT: ALL CLEAR`.
312
322
  Hard enforcement for Step 7:
323
+ - Before spawning `atomic-planner`, resolve `${plan-path}` by enumerating
324
+ existing `${feature-folder}/plan*.md` files. Reuse the first existing file
325
+ in deterministic filename order, including timestamped scaffolded plans.
313
326
  - The planning route MUST be `atomic-planner -> atomic-executor` for preflight validation.
314
327
  - The planner MUST update `${plan-path}` in place and MUST NOT create additional `plan.*.md` files for revisions.
315
328
  - The approved plan MUST include explicit Phase 0 baseline evidence tasks and explicit final-QA evidence or coverage tasks for each language in scope where policy requires them.
@@ -76,6 +76,8 @@ Every agent named above must exist as a native Codex agent under `.codex/agents/
76
76
  - Do not rename, back up, or create sidecar checkpoint files.
77
77
  - Do not create or edit `${feature-folder}/issue.md`, `${feature-folder}/spec.md`, `${feature-folder}/user-story.md`, or `plan*.md` until lifecycle setup succeeds.
78
78
  - Do not persist placeholder lifecycle values such as `NONE`, `TBD`, or empty strings once lifecycle setup begins.
79
+ - After active folder creation and before planner delegation, resolve `${plan-path}` by enumerating existing `${feature-folder}/plan*.md` files in deterministic filename order. If a scaffolded timestamped plan exists, persist and delegate against that exact file. Do not default to `${feature-folder}/plan.md` and do not create a second plan artifact when a plan already exists.
80
+ - For issue #306, the canonical existing plan path is `docs/features/active/2026-07-04-codex-agent-role-config-306/plan.2026-07-04T13-47.md`; reuse that exact file and do not create `docs/features/active/2026-07-04-codex-agent-role-config-306/plan.md`.
79
81
 
80
82
  ## Checkpoint Persistence
81
83
 
@@ -133,17 +135,16 @@ The MCP validator and required CI checks are the hard completion boundary. There
133
135
  Do not claim mission completion unless all required delegations completed with receipts and the required orchestration artifacts exist on disk.
134
136
  '''
135
137
 
136
- [mcp_servers.drm-copilot]
137
- enabled = true
138
-
139
- [skills.config]
140
- policy-compliance-order = true
141
- orchestrate = true
142
- orchestrator-workflow = true
143
- feature-promotion-lifecycle = true
144
- repo-automation-adapter = true
145
- atomic-plan-contract = true
146
- acceptance-criteria-tracking = true
147
- evidence-and-timestamp-conventions = true
148
- pr-context-artifacts = true
149
- pr-base-branch-merge-base = true
138
+ [skills]
139
+ config = [
140
+ { name = "policy-compliance-order", enabled = true },
141
+ { name = "orchestrate", enabled = true },
142
+ { name = "orchestrator-workflow", enabled = true },
143
+ { name = "feature-promotion-lifecycle", enabled = true },
144
+ { name = "repo-automation-adapter", enabled = true },
145
+ { name = "atomic-plan-contract", enabled = true },
146
+ { name = "acceptance-criteria-tracking", enabled = true },
147
+ { name = "evidence-and-timestamp-conventions", enabled = true },
148
+ { name = "pr-context-artifacts", enabled = true },
149
+ { name = "pr-base-branch-merge-base", enabled = true },
150
+ ]
@@ -23,9 +23,11 @@
23
23
  variables.feature-folder);
24
24
  - a ci_gate object with conclusion == "success" and a non-empty head_sha.
25
25
 
26
- The block reason names the specific missing evidence so the caller can
27
- remediate. When completion is not asserted, the write is allowed
28
- (backward compatibility).
26
+ When completion evidence is missing the hook emits a PreToolUse JSON
27
+ response with hookSpecificOutput.permissionDecision='deny' and a reason that
28
+ names the specific missing evidence so the caller can remediate. When
29
+ completion is not asserted, the write is allowed via
30
+ hookSpecificOutput.permissionDecision='allow' (backward compatibility).
29
31
 
30
32
  Edit tool calls supply only old_string/new_string (a partial patch) and
31
33
  cannot be reliably validated without the full target file content, so they
@@ -39,6 +41,11 @@
39
41
  [CmdletBinding()]
40
42
  param()
41
43
 
44
+ # Dot-source the shared validation helpers. Guarded so a missing file produces a
45
+ # clear error and so dot-sourcing this hook in tests loads the helpers too.
46
+ $script:CompletionHelpersPath = Join-Path $PSScriptRoot 'enforce-completion-helpers.ps1'
47
+ . $script:CompletionHelpersPath
48
+
42
49
  function ConvertFrom-CheckpointJson {
43
50
  <#
44
51
  .SYNOPSIS
@@ -53,6 +60,29 @@ function ConvertFrom-CheckpointJson {
53
60
  return $Json | ConvertFrom-Json -ErrorAction Stop
54
61
  }
55
62
 
63
+ function Get-CheckpointFileContent {
64
+ <#
65
+ .SYNOPSIS
66
+ Reads the on-disk checkpoint content for the read-then-validate Edit path.
67
+ .DESCRIPTION
68
+ Returns the full file text when the path resolves to a file on disk, or
69
+ $null when the file does not exist. Tests inject a CheckpointReader
70
+ scriptblock instead of mocking this function so no temporary files are
71
+ required.
72
+ #>
73
+ [CmdletBinding()]
74
+ [OutputType([string])]
75
+ param(
76
+ [Parameter(Mandatory)]
77
+ [string] $Path
78
+ )
79
+
80
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
81
+ return $null
82
+ }
83
+ return Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
84
+ }
85
+
56
86
  function Test-IsCheckpointPath {
57
87
  [CmdletBinding()]
58
88
  [OutputType([bool])]
@@ -149,7 +179,13 @@ function Get-MissingCompletionEvidence {
149
179
  param(
150
180
  [Parameter(Mandatory)]
151
181
  [AllowNull()]
152
- $Payload
182
+ $Payload,
183
+
184
+ [Parameter(Mandatory = $false)]
185
+ [scriptblock] $FolderExistsCheck = { param($p) Test-Path -LiteralPath $p -PathType Container },
186
+
187
+ [Parameter(Mandatory = $false)]
188
+ [scriptblock] $RoutingMatrixReader
153
189
  )
154
190
 
155
191
  $missing = @()
@@ -158,16 +194,17 @@ function Get-MissingCompletionEvidence {
158
194
  if (-not $issueNum -and $null -ne $Payload -and ($Payload.PSObject.Properties.Name -contains 'variables')) {
159
195
  $issueNum = Get-CheckpointStringValue -Payload $Payload.variables -Name 'issue-num'
160
196
  }
161
- if (-not $issueNum) {
162
- $missing += 'issue-num'
197
+ if (-not (Test-IsValidIssueNum -Value $issueNum)) {
198
+ # Name the offending value so sentinel/placeholder inputs are explicit.
199
+ $missing += "issue-num value '$issueNum' is not a valid issue number (must be digits-only)"
163
200
  }
164
201
 
165
202
  $featureFolder = Get-CheckpointStringValue -Payload $Payload -Name 'feature-folder'
166
203
  if (-not $featureFolder -and $null -ne $Payload -and ($Payload.PSObject.Properties.Name -contains 'variables')) {
167
204
  $featureFolder = Get-CheckpointStringValue -Payload $Payload.variables -Name 'feature-folder'
168
205
  }
169
- if (-not $featureFolder) {
170
- $missing += 'feature-folder'
206
+ if (-not (Test-IsValidFeatureFolder -Value $featureFolder -FolderExistsCheck $FolderExistsCheck)) {
207
+ $missing += "feature-folder value '$featureFolder' is not a valid feature folder (must be under docs/features/active/ and exist)"
171
208
  }
172
209
 
173
210
  $ciGate = $null
@@ -188,7 +225,14 @@ function Get-MissingCompletionEvidence {
188
225
  }
189
226
  }
190
227
 
191
- if ($issueNum -eq '232') {
228
+ # PR-gate evidence is required only when the checkpoint's selected route
229
+ # opts into it via requires_pr_gate in the routing matrix. This replaces the
230
+ # former issue-number special-casing with route-driven enforcement.
231
+ $prGateArgs = @{ Payload = $Payload }
232
+ if ($PSBoundParameters.ContainsKey('RoutingMatrixReader') -and $null -ne $RoutingMatrixReader) {
233
+ $prGateArgs['RoutingMatrixReader'] = $RoutingMatrixReader
234
+ }
235
+ if (Test-RouteRequiresPrGate @prGateArgs) {
192
236
  $prGate = $null
193
237
  if ($null -ne $Payload -and ($Payload.PSObject.Properties.Name -contains 'pr_gate')) {
194
238
  $prGate = $Payload.pr_gate
@@ -213,6 +257,60 @@ function Get-MissingCompletionEvidence {
213
257
  return [string[]]$missing
214
258
  }
215
259
 
260
+ function Resolve-EditedCheckpointContent {
261
+ <#
262
+ .SYNOPSIS
263
+ Returns the patched checkpoint content for an Edit-tool call, or $null
264
+ when the patch cannot be applied against the on-disk checkpoint.
265
+ .DESCRIPTION
266
+ Implements the read-then-validate Edit path. When the tool input carries
267
+ an old_string (an Edit patch), the on-disk checkpoint is read through the
268
+ injectable CheckpointReader seam and the old_string -> new_string
269
+ replacement is applied in memory (no on-disk mutation). Returns $null
270
+ when there is no old_string, the on-disk file does not exist, or the
271
+ old_string is not present in the on-disk content, signalling the caller
272
+ to allow (defer).
273
+ #>
274
+ [CmdletBinding()]
275
+ [OutputType([string])]
276
+ param(
277
+ [Parameter(Mandatory)]
278
+ [AllowNull()]
279
+ $ToolInput,
280
+
281
+ [Parameter(Mandatory)]
282
+ [scriptblock] $CheckpointReader
283
+ )
284
+
285
+ $oldString = $null
286
+ if ($null -ne $ToolInput -and ($ToolInput.PSObject.Properties.Name -contains 'old_string')) {
287
+ $oldString = [string]$ToolInput.old_string
288
+ }
289
+ if ([string]::IsNullOrEmpty($oldString)) {
290
+ return $null
291
+ }
292
+
293
+ $newString = ''
294
+ if ($ToolInput.PSObject.Properties.Name -contains 'new_string') {
295
+ $newString = [string]$ToolInput.new_string
296
+ }
297
+
298
+ $onDisk = & $CheckpointReader 'artifacts/orchestration/orchestrator-state.json'
299
+ if ([string]::IsNullOrEmpty([string]$onDisk)) {
300
+ # The on-disk checkpoint does not exist (or is empty); cannot patch.
301
+ return $null
302
+ }
303
+
304
+ $onDiskText = [string]$onDisk
305
+ if (-not $onDiskText.Contains($oldString)) {
306
+ # The old_string is not present, so the patch does not apply here.
307
+ return $null
308
+ }
309
+
310
+ # Apply the patch in memory using a literal (non-regex) replacement.
311
+ return $onDiskText.Replace($oldString, $newString)
312
+ }
313
+
216
314
  function Invoke-CompletionConsistencyDecision {
217
315
  <#
218
316
  .SYNOPSIS
@@ -222,11 +320,20 @@ function Invoke-CompletionConsistencyDecision {
222
320
  [CmdletBinding()]
223
321
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
224
322
  param(
225
- [string] $ToolInputRaw
323
+ [string] $ToolInputRaw,
324
+
325
+ [Parameter(Mandatory = $false)]
326
+ [scriptblock] $FolderExistsCheck = { param($p) Test-Path -LiteralPath $p -PathType Container },
327
+
328
+ [Parameter(Mandatory = $false)]
329
+ [scriptblock] $CheckpointReader = { param($Path) Get-CheckpointFileContent -Path $Path },
330
+
331
+ [Parameter(Mandatory = $false)]
332
+ [scriptblock] $RoutingMatrixReader
226
333
  )
227
334
 
228
335
  if (-not $ToolInputRaw) {
229
- return [ordered]@{ decision = 'allow' }
336
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
230
337
  }
231
338
 
232
339
  try {
@@ -238,19 +345,25 @@ function Invoke-CompletionConsistencyDecision {
238
345
 
239
346
  $filePath = $toolInput.file_path
240
347
  if (-not $filePath) {
241
- return [ordered]@{ decision = 'allow' }
348
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
242
349
  }
243
350
 
244
351
  $normalized = $filePath -replace '\\', '/'
245
352
  if (-not (Test-IsCheckpointPath -NormalizedPath $normalized)) {
246
- return [ordered]@{ decision = 'allow' }
353
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
247
354
  }
248
355
 
249
- # Write tool: validate the content payload. Edit tool: partial new_string is
250
- # not reliable without the full target file content, so allow.
356
+ # Write tool: validate the content payload directly. Edit tool: no content is
357
+ # supplied, so read the on-disk checkpoint through the injectable seam and
358
+ # apply the old_string -> new_string patch in memory (read-then-validate).
251
359
  $content = $toolInput.content
252
360
  if (-not $content) {
253
- return [ordered]@{ decision = 'allow' }
361
+ $content = Resolve-EditedCheckpointContent -ToolInput $toolInput -CheckpointReader $CheckpointReader
362
+ if (-not $content) {
363
+ # No content, and the Edit could not be resolved against on-disk
364
+ # state (missing file or non-matching patch): defer and allow.
365
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
366
+ }
254
367
  }
255
368
 
256
369
  try {
@@ -259,26 +372,29 @@ function Invoke-CompletionConsistencyDecision {
259
372
  catch {
260
373
  # The content itself is not valid JSON. Let downstream tools surface the
261
374
  # error rather than blocking with a misleading reason here.
262
- return [ordered]@{ decision = 'allow' }
375
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
263
376
  }
264
377
 
265
378
  if (-not (Test-CompletionAsserted -Payload $payload)) {
266
- return [ordered]@{ decision = 'allow' }
379
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
267
380
  }
268
381
 
269
- $missing = Get-MissingCompletionEvidence -Payload $payload
270
- if ($missing.Count -eq 0) {
271
- return [ordered]@{ decision = 'allow' }
382
+ $missingArgs = @{ Payload = $payload; FolderExistsCheck = $FolderExistsCheck }
383
+ if ($PSBoundParameters.ContainsKey('RoutingMatrixReader') -and $null -ne $RoutingMatrixReader) {
384
+ $missingArgs['RoutingMatrixReader'] = $RoutingMatrixReader
272
385
  }
273
-
274
- $issueContext = ''
275
- if ((Get-CheckpointStringValue -Payload $payload -Name 'issue-num') -eq '232') {
276
- $issueContext = ' Issue #232 requires pr_gate evidence and current-head ci_gate evidence.'
386
+ $missing = Get-MissingCompletionEvidence @missingArgs
387
+ if ($missing.Count -eq 0) {
388
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
277
389
  }
278
390
 
391
+ $reason = "COMPLETION_CONSISTENCY_BLOCKED: the checkpoint asserts completion but is missing required completion evidence: $($missing -join ', '). A completion-asserting checkpoint must include a non-empty issue-num, a non-empty feature-folder, and a ci_gate object with conclusion == 'success' and a non-empty head_sha; routes whose requires_pr_gate is true must also include pr_gate evidence with a matching head_sha. Supply the missing evidence or remove the completion assertion."
279
392
  return [ordered]@{
280
- decision = 'block'
281
- reason = "COMPLETION_CONSISTENCY_BLOCKED: the checkpoint asserts completion but is missing required completion evidence: $($missing -join ', ').$issueContext A completion-asserting checkpoint must include a non-empty issue-num, a non-empty feature-folder, and a ci_gate object with conclusion == 'success' and a non-empty head_sha. Supply the missing evidence or remove the completion assertion."
393
+ hookSpecificOutput = [ordered]@{
394
+ hookEventName = 'PreToolUse'
395
+ permissionDecision = 'deny'
396
+ permissionDecisionReason = $reason
397
+ }
282
398
  }
283
399
  }
284
400
 
@@ -295,6 +411,6 @@ catch {
295
411
  exit 1
296
412
  }
297
413
 
298
- $decision | ConvertTo-Json -Compress | Write-Output
414
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
299
415
 
300
416
  exit 0
@@ -0,0 +1,163 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Dot-sourced helper functions for enforce-completion-consistency.ps1.
4
+
5
+ .DESCRIPTION
6
+ Provides testable validation helpers used by the completion-consistency
7
+ PreToolUse hook:
8
+
9
+ - Test-IsValidIssueNum: rejects sentinel/placeholder and non-digit issue
10
+ numbers; accepts digits-only strings.
11
+ - Test-IsValidFeatureFolder: rejects sentinel/placeholder feature folders
12
+ and folders not anchored under docs/features/active/<segment>; optionally
13
+ verifies on-disk existence through an injectable scriptblock seam.
14
+
15
+ This script is dot-sourced by enforce-completion-consistency.ps1. It contains
16
+ no entrypoint logic, so dot-sourcing it in tests has no side effects.
17
+
18
+ .NOTES
19
+ Compatible with PowerShell 7+.
20
+ #>
21
+ [CmdletBinding()]
22
+ param()
23
+
24
+ # Sentinel/placeholder values that must never satisfy a presence check.
25
+ $script:CompletionEvidenceSentinels = @('n/a', 'none', 'tbd')
26
+
27
+ function Test-IsValidIssueNum {
28
+ <#
29
+ .SYNOPSIS
30
+ Returns $true only for a digits-only issue number.
31
+ .DESCRIPTION
32
+ Returns $false when the value is empty, whitespace-only, a sentinel
33
+ (n/a, none, tbd; case-insensitive), or contains any non-digit character.
34
+ Returns $true only when the trimmed value matches ^\d+$.
35
+ #>
36
+ [CmdletBinding()]
37
+ [OutputType([bool])]
38
+ param(
39
+ [Parameter(Mandatory = $true)]
40
+ [AllowNull()]
41
+ [AllowEmptyString()]
42
+ [string] $Value
43
+ )
44
+
45
+ if ([string]::IsNullOrWhiteSpace($Value)) {
46
+ return $false
47
+ }
48
+ $trimmed = $Value.Trim()
49
+ # Sentinel placeholders are explicitly rejected even though they are
50
+ # non-empty strings; the comparison is case-insensitive.
51
+ if ($script:CompletionEvidenceSentinels -contains $trimmed.ToLowerInvariant()) {
52
+ return $false
53
+ }
54
+ return $trimmed -match '^\d+$'
55
+ }
56
+
57
+ function Test-IsValidFeatureFolder {
58
+ <#
59
+ .SYNOPSIS
60
+ Returns $true only for a sentinel-free feature folder anchored under
61
+ docs/features/active/ with a non-empty trailing segment that exists.
62
+ .DESCRIPTION
63
+ Returns $false when the value is empty, whitespace-only, or a sentinel
64
+ (n/a, none, tbd; case-insensitive). Requires the value to start with
65
+ 'docs/features/active/' and to carry at least one additional non-empty
66
+ path segment after that prefix. Invokes the injectable FolderExistsCheck
67
+ scriptblock (default Test-Path -PathType Container) and returns $false
68
+ when it reports the folder does not exist.
69
+ #>
70
+ [CmdletBinding()]
71
+ [OutputType([bool])]
72
+ param(
73
+ [Parameter(Mandatory = $true)]
74
+ [AllowNull()]
75
+ [AllowEmptyString()]
76
+ [string] $Value,
77
+
78
+ [Parameter(Mandatory = $false)]
79
+ [scriptblock] $FolderExistsCheck = { param($p) Test-Path -LiteralPath $p -PathType Container }
80
+ )
81
+
82
+ if ([string]::IsNullOrWhiteSpace($Value)) {
83
+ return $false
84
+ }
85
+ $trimmed = $Value.Trim()
86
+ if ($script:CompletionEvidenceSentinels -contains $trimmed.ToLowerInvariant()) {
87
+ return $false
88
+ }
89
+
90
+ $prefix = 'docs/features/active/'
91
+ $normalized = $trimmed -replace '\\', '/'
92
+ if (-not $normalized.StartsWith($prefix)) {
93
+ return $false
94
+ }
95
+
96
+ # Require a non-empty segment after the active/ prefix so the bare prefix is
97
+ # not accepted as a valid folder.
98
+ $suffix = $normalized.Substring($prefix.Length).TrimEnd('/')
99
+ if ([string]::IsNullOrWhiteSpace($suffix)) {
100
+ return $false
101
+ }
102
+
103
+ return [bool](& $FolderExistsCheck $normalized)
104
+ }
105
+
106
+ function Test-RouteRequiresPrGate {
107
+ <#
108
+ .SYNOPSIS
109
+ Returns $true when the payload's selected route opts into the PR gate.
110
+ .DESCRIPTION
111
+ Resolves the route id from the payload (route_id, falling back to
112
+ path_selected), looks it up in the routing matrix returned by the
113
+ injectable RoutingMatrixReader seam, and returns $true only when that
114
+ route's requires_pr_gate value is the boolean $true. A missing route id,
115
+ an unknown route, a matrix without routes, or a missing/false
116
+ requires_pr_gate returns $false. This generalizes the former issue-232
117
+ special-casing into a route-driven check.
118
+ #>
119
+ [CmdletBinding()]
120
+ [OutputType([bool])]
121
+ param(
122
+ [Parameter(Mandatory)]
123
+ [AllowNull()]
124
+ $Payload,
125
+
126
+ [Parameter(Mandatory = $false)]
127
+ [scriptblock] $RoutingMatrixReader = {
128
+ $configPath = Join-Path $PSScriptRoot '../../config/orchestration-routing.json'
129
+ if (-not (Test-Path -LiteralPath $configPath)) { return $null }
130
+ Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
131
+ }
132
+ )
133
+
134
+ if ($null -eq $Payload) {
135
+ return $false
136
+ }
137
+
138
+ # Resolve the selected route id, preferring route_id over path_selected.
139
+ $routeId = ''
140
+ if ($Payload.PSObject.Properties.Name -contains 'route_id') {
141
+ $routeId = ([string]$Payload.route_id).Trim()
142
+ }
143
+ if (-not $routeId -and ($Payload.PSObject.Properties.Name -contains 'path_selected')) {
144
+ $routeId = ([string]$Payload.path_selected).Trim()
145
+ }
146
+ if (-not $routeId) {
147
+ return $false
148
+ }
149
+
150
+ $matrix = & $RoutingMatrixReader
151
+ if ($null -eq $matrix -or -not ($matrix.PSObject.Properties.Name -contains 'routes')) {
152
+ return $false
153
+ }
154
+ $routes = $matrix.routes
155
+ if ($null -eq $routes -or -not ($routes.PSObject.Properties.Name -contains $routeId)) {
156
+ return $false
157
+ }
158
+ $route = $routes.$routeId
159
+ if ($null -eq $route -or -not ($route.PSObject.Properties.Name -contains 'requires_pr_gate')) {
160
+ return $false
161
+ }
162
+ return ([bool]$route.requires_pr_gate -eq $true)
163
+ }