@danmoisan/drm-copilot-mcp 0.0.5 → 1.0.0

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 (96) hide show
  1. package/out/mcp-server.js +10268 -380
  2. package/package.json +2 -2
  3. package/resources/claude-customizations/.claude/agents/orchestrator.md +6 -2
  4. package/resources/claude-customizations/.claude/agents/pr-author.md +78 -0
  5. package/resources/claude-customizations/.claude/agents/task-researcher.md +10 -6
  6. package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +68 -10
  7. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +148 -11
  8. package/resources/claude-customizations/.claude/hooks/enforce-completion-helpers.ps1 +163 -0
  9. package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +40 -14
  10. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +198 -0
  11. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +153 -10
  12. package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +68 -1
  13. package/resources/claude-customizations/.claude/hooks/validate-pr-author-output.ps1 +136 -0
  14. package/resources/claude-customizations/.claude/hooks/validate-task-researcher-output.ps1 +22 -6
  15. package/resources/claude-customizations/.claude/settings.json +22 -0
  16. package/resources/claude-customizations/.claude/skills/evidence-and-timestamp-conventions/SKILL.md +0 -1
  17. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +76 -2
  18. package/resources/claude-customizations/.claude/skills/research-issue/SKILL.md +5 -3
  19. package/resources/claude-customizations/.claude-variants/csharp-legacy/agents/csharp-typed-engineer.md +69 -0
  20. package/resources/claude-customizations/.claude-variants/csharp-legacy/rules/csharp.md +96 -0
  21. package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +77 -0
  22. package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/invoke-csharp-engineer/SKILL.md +64 -0
  23. package/resources/claude-customizations/pack-manifests/core.json +65 -0
  24. package/resources/claude-customizations/pack-manifests/csharp-legacy.json +11 -0
  25. package/resources/claude-customizations/pack-manifests/csharp-modern.json +12 -0
  26. package/resources/claude-customizations/pack-manifests/powershell.json +14 -0
  27. package/resources/claude-customizations/pack-manifests/python.json +14 -0
  28. package/resources/claude-customizations/pack-manifests/typescript.json +9 -0
  29. package/resources/codex-and-agents-customizations/.agents/skills/feature-promotion-lifecycle/SKILL.md +30 -13
  30. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +113 -10
  31. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-workflow/SKILL.md +33 -8
  32. package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +12 -7
  33. package/resources/codex-and-agents-customizations/.codex/config.toml +65 -6
  34. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-checkpoint-monotonic.ps1 +303 -0
  35. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-completion-consistency.ps1 +300 -0
  36. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +148 -0
  37. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-pr-author-skill.ps1 +336 -0
  38. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +83 -17
  39. package/resources/config/orchestration-routing.json +5 -13
  40. package/resources/customizations/.github/agents/pr-author.agent.md +25 -0
  41. package/resources/customizations/.github/agents/task-researcher.agent.md +4 -4
  42. package/resources/customizations/.github/prompts/fillout-prd-feature.prompt.md +1 -1
  43. package/resources/customizations/.github/prompts/research-issue.prompt.md +4 -3
  44. package/resources/codex-and-agents-customizations/.codex/scripts/post-codex-worktree-session.ps1 +0 -5
  45. package/resources/scripts/dev_tools/__init__.py +0 -0
  46. package/resources/scripts/dev_tools/_orchestrator_state_human_interaction.py +0 -127
  47. package/resources/scripts/dev_tools/_orchestrator_state_routing.py +0 -216
  48. package/resources/scripts/dev_tools/agentic_sync.py +0 -819
  49. package/resources/scripts/dev_tools/codex_native_converter/__init__.py +0 -11
  50. package/resources/scripts/dev_tools/codex_native_converter/__main__.py +0 -6
  51. package/resources/scripts/dev_tools/codex_native_converter/cli.py +0 -11
  52. package/resources/scripts/dev_tools/new_active_feature_folder.py +0 -79
  53. package/resources/scripts/dev_tools/new_active_feature_folder_docs.py +0 -268
  54. package/resources/scripts/dev_tools/new_active_feature_folder_flow.py +0 -366
  55. package/resources/scripts/dev_tools/new_active_feature_folder_io.py +0 -306
  56. package/resources/scripts/dev_tools/new_active_feature_folder_markdown.py +0 -252
  57. package/resources/scripts/dev_tools/new_active_feature_folder_models.py +0 -136
  58. package/resources/scripts/dev_tools/new_potential_bug_entry.py +0 -465
  59. package/resources/scripts/dev_tools/potential_to_issue.py +0 -421
  60. package/resources/scripts/dev_tools/potential_to_issue_content.py +0 -212
  61. package/resources/scripts/dev_tools/pr_context/__init__.py +0 -0
  62. package/resources/scripts/dev_tools/pr_context/collector.py +0 -619
  63. package/resources/scripts/dev_tools/pr_context/feature_docs.py +0 -349
  64. package/resources/scripts/dev_tools/pr_context/git.py +0 -153
  65. package/resources/scripts/dev_tools/pr_context/github.py +0 -549
  66. package/resources/scripts/dev_tools/pr_context/models.py +0 -198
  67. package/resources/scripts/dev_tools/pr_context/render.py +0 -342
  68. package/resources/scripts/dev_tools/pr_context/render_feature_excerpts.py +0 -256
  69. package/resources/scripts/dev_tools/pr_context/render_pr_helpers.py +0 -291
  70. package/resources/scripts/dev_tools/pr_context/summary_helpers.py +0 -386
  71. package/resources/scripts/dev_tools/pr_context/verification_evidence.py +0 -171
  72. package/resources/scripts/dev_tools/prompt_mode_contract.py +0 -152
  73. package/resources/scripts/dev_tools/push_down_claude_customizations.py +0 -374
  74. package/resources/scripts/dev_tools/push_down_codex_and_agents_customizations.py +0 -139
  75. package/resources/scripts/dev_tools/push_down_copilot_customizations.py +0 -504
  76. package/resources/scripts/dev_tools/push_down_copilot_customizations_filesystem.py +0 -217
  77. package/resources/scripts/dev_tools/push_down_copilot_customizations_rewrites.py +0 -293
  78. package/resources/scripts/dev_tools/resolve_file_prompt.py +0 -457
  79. package/resources/scripts/dev_tools/resolve_hard_lock_prompt.py +0 -444
  80. package/resources/scripts/dev_tools/validate_orchestration_artifacts.py +0 -246
  81. package/resources/scripts/dev_tools/validate_orchestration_review_artifacts.py +0 -107
  82. package/resources/scripts/dev_tools/validate_orchestrator_state.py +0 -428
  83. package/resources/scripts/dev_tools/validate_policy_audit_artifact.py +0 -448
  84. package/resources/templates/codex_native_converter.py +0 -35
  85. package/resources/templates/collect_commit_context.py +0 -212
  86. package/resources/templates/collect_pr_context.py +0 -74
  87. package/resources/templates/hello_python.py +0 -11
  88. package/resources/templates/new_active_feature_folder.py +0 -67
  89. package/resources/templates/new_potential_bug_entry.py +0 -54
  90. package/resources/templates/potential_to_issue.py +0 -55
  91. package/resources/templates/push_down_claude_customizations.py +0 -409
  92. package/resources/templates/push_down_codex_and_agents_customizations.py +0 -95
  93. package/resources/templates/push_down_copilot_customizations.py +0 -124
  94. package/resources/templates/resolve_atomic_plan_prompt.py +0 -75
  95. package/resources/templates/resolve_hard_lock_prompt.py +0 -65
  96. package/resources/templates/validate_orchestration_artifacts.py +0 -55
@@ -17,12 +17,15 @@
17
17
  - artifacts/evidence/
18
18
  - artifacts/regression-testing/
19
19
  - artifacts/post-change/
20
+ - artifacts/research/
20
21
 
21
22
  All other paths pass through, including canonical evidence paths of the form
22
23
  <FEATURE>/evidence/<kind>/ and permitted artifacts/ sub-paths such as
23
- artifacts/orchestration/, artifacts/research/, artifacts/pr_context,
24
- artifacts/reviews/, artifacts/status/, artifacts/python/, artifacts/pester/,
25
- and artifacts/csharp/.
24
+ artifacts/orchestration/, artifacts/pr_context, artifacts/reviews/,
25
+ artifacts/status/, artifacts/python/, artifacts/pester/, and
26
+ artifacts/csharp/. Research output is no longer an artifacts/ sub-path; it
27
+ is written to the tracked roots docs/features/<feature>/research/
28
+ (feature-associated) or docs/research/ (one-off).
26
29
 
27
30
  If the file_path resolves to a forbidden prefix, the script writes a JSON response
28
31
  to stdout with 'decision': 'block' and exits with code 0 so Claude Code surfaces
@@ -61,7 +64,8 @@ function Test-EvidenceLocationForbidden {
61
64
  'artifacts/coverage/',
62
65
  'artifacts/evidence/',
63
66
  'artifacts/regression-testing/',
64
- 'artifacts/post-change/'
67
+ 'artifacts/post-change/',
68
+ 'artifacts/research/'
65
69
  )
66
70
 
67
71
  # Match the prefix either at the start of the string or after any directory separator,
@@ -133,18 +137,40 @@ function Invoke-EvidenceLocationDecision {
133
137
  return [ordered]@{ decision = 'allow' }
134
138
  }
135
139
 
140
+ function Invoke-EvidenceLocationEntryPoint {
141
+ <#
142
+ .SYNOPSIS
143
+ Runs the evidence-location decision and returns the process exit code.
144
+ .DESCRIPTION
145
+ Wraps the dispatch logic that the hook entry point performs so it can be
146
+ exercised by unit tests. On success it writes the compact JSON decision to
147
+ the output stream and returns 0. On a hard failure (malformed JSON) it
148
+ writes the error record and returns 1. This function does not call exit;
149
+ the thin entry-point wiring converts the returned code into a process exit.
150
+ .PARAMETER ToolInputRaw
151
+ The raw JSON string from $env:CLAUDE_TOOL_INPUT.
152
+ #>
153
+ [CmdletBinding()]
154
+ [OutputType([int])]
155
+ param(
156
+ [string] $ToolInputRaw = $env:CLAUDE_TOOL_INPUT
157
+ )
158
+
159
+ try {
160
+ $decision = Invoke-EvidenceLocationDecision -ToolInputRaw $ToolInputRaw
161
+ } catch {
162
+ Write-Error $_
163
+ return 1
164
+ }
165
+
166
+ $decision | ConvertTo-Json -Compress | Write-Output
167
+
168
+ return 0
169
+ }
170
+
136
171
  # Guard allows dot-sourcing in tests without executing the entrypoint.
137
172
  if ($MyInvocation.InvocationName -eq '.') {
138
173
  return
139
174
  }
140
175
 
141
- try {
142
- $decision = Invoke-EvidenceLocationDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
143
- } catch {
144
- Write-Error $_
145
- exit 1
146
- }
147
-
148
- $decision | ConvertTo-Json -Compress | Write-Output
149
-
150
- exit 0
176
+ exit (Invoke-EvidenceLocationEntryPoint)
@@ -0,0 +1,198 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Blocks implementation operations before orchestration readiness exists.
4
+ #>
5
+ [CmdletBinding()]
6
+ param()
7
+
8
+ $script:CheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
9
+
10
+ function ConvertFrom-CheckpointJson {
11
+ [CmdletBinding()]
12
+ param([Parameter(Mandatory)][string] $Json)
13
+
14
+ return $Json | ConvertFrom-Json -ErrorAction Stop
15
+ }
16
+
17
+ function Get-StringProperty {
18
+ [CmdletBinding()]
19
+ [OutputType([string])]
20
+ param(
21
+ [Parameter(Mandatory)][AllowNull()] $Value,
22
+ [Parameter(Mandatory)][string] $Name
23
+ )
24
+
25
+ if ($null -eq $Value -or -not ($Value.PSObject.Properties.Name -contains $Name)) {
26
+ return ''
27
+ }
28
+ return ([string]$Value.$Name).Trim()
29
+ }
30
+
31
+ function Test-FeatureDocumentationOrEvidencePath {
32
+ [CmdletBinding()]
33
+ [OutputType([bool])]
34
+ param([Parameter(Mandatory)][string] $NormalizedPath)
35
+
36
+ return $NormalizedPath.StartsWith('docs/features/active/')
37
+ }
38
+
39
+ function Test-ImplementationPath {
40
+ [CmdletBinding()]
41
+ [OutputType([bool])]
42
+ param([Parameter(Mandatory)][string] $NormalizedPath)
43
+
44
+ if (Test-FeatureDocumentationOrEvidencePath -NormalizedPath $NormalizedPath) {
45
+ return $false
46
+ }
47
+ if ($NormalizedPath -eq $script:CheckpointPath) {
48
+ return $false
49
+ }
50
+ return $NormalizedPath -match '\.(py|ps1|psm1|ts|tsx|js|jsx|cs|json|yml|yaml)$'
51
+ }
52
+
53
+ function Test-ImplementationCommand {
54
+ [CmdletBinding()]
55
+ [OutputType([bool])]
56
+ param([Parameter(Mandatory)][string] $Command)
57
+
58
+ $normalizedCommand = $Command.Trim()
59
+ if (-not $normalizedCommand) {
60
+ return $false
61
+ }
62
+
63
+ $implementationCommandPatterns = @(
64
+ '(^|\s)git\s+(add|commit)\b',
65
+ '(^|\s)(poetry\s+run\s+)?(black|ruff|pyright|pytest)\b',
66
+ '(^|\s)npm\s+.*\s+(prettier|lint|typecheck|test:unit)\b',
67
+ '(^|\s)npx\s+(prettier|eslint|tsc|jest)\b',
68
+ '(^|\s)pwsh\s+.*(Invoke-Pester|tests/scripts/)'
69
+ )
70
+
71
+ foreach ($pattern in $implementationCommandPatterns) {
72
+ if ($normalizedCommand -match $pattern) {
73
+ return $true
74
+ }
75
+ }
76
+ return $false
77
+ }
78
+
79
+ function Test-ImplementationDelegation {
80
+ [CmdletBinding()]
81
+ [OutputType([bool])]
82
+ param([Parameter(Mandatory)][AllowNull()] $ToolInput)
83
+
84
+ if ($null -eq $ToolInput) {
85
+ return $false
86
+ }
87
+
88
+ $payloadText = ($ToolInput | ConvertTo-Json -Depth 20 -Compress)
89
+ return $payloadText -match '(python-typed-engineer|powershell-typed-engineer|typescript-engineer|csharp-typed-engineer|atomic-executor|implementation|execute)'
90
+ }
91
+
92
+ function Test-OrchestrationReady {
93
+ [CmdletBinding()]
94
+ [OutputType([bool])]
95
+ param([Parameter(Mandatory)][AllowNull()] $Payload)
96
+
97
+ if ($null -eq $Payload) {
98
+ return $false
99
+ }
100
+ $issueNum = Get-StringProperty -Value $Payload -Name 'issue-num'
101
+ $featureFolder = Get-StringProperty -Value $Payload -Name 'feature-folder'
102
+ $routeId = Get-StringProperty -Value $Payload -Name 'route_id'
103
+ if (-not $routeId) {
104
+ $routeId = Get-StringProperty -Value $Payload -Name 'path_selected'
105
+ }
106
+ $lifecycleReady = $false
107
+ if ($Payload.PSObject.Properties.Name -contains 'lifecycle_ready') {
108
+ $lifecycleReady = [bool]$Payload.lifecycle_ready
109
+ }
110
+
111
+ if (-not $issueNum -or -not $featureFolder -or -not $routeId -or -not $lifecycleReady) {
112
+ return $false
113
+ }
114
+
115
+ return (
116
+ $featureFolder.StartsWith('docs/features/active/') -and
117
+ $routeId -and
118
+ $lifecycleReady
119
+ )
120
+ }
121
+
122
+ function Get-CheckpointContent {
123
+ [CmdletBinding()]
124
+ [OutputType([string])]
125
+ param()
126
+
127
+ if (-not (Test-Path -LiteralPath $script:CheckpointPath)) {
128
+ return ''
129
+ }
130
+ return Get-Content -Raw -LiteralPath $script:CheckpointPath
131
+ }
132
+
133
+ function Invoke-OrchestrationPreimplementationGateDecision {
134
+ [CmdletBinding()]
135
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
136
+ param(
137
+ [string] $ToolInputRaw,
138
+ [string] $CheckpointRaw
139
+ )
140
+
141
+ if (-not $ToolInputRaw) {
142
+ return [ordered]@{ decision = 'allow' }
143
+ }
144
+ try {
145
+ $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
146
+ } catch {
147
+ throw "enforce-orchestration-preimplementation-gate hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
148
+ }
149
+
150
+ $requiresReadyCheckpoint = $false
151
+ $filePath = Get-StringProperty -Value $toolInput -Name 'file_path'
152
+ if ($filePath) {
153
+ $normalized = ([string]$filePath) -replace '\\', '/'
154
+ $requiresReadyCheckpoint = Test-ImplementationPath -NormalizedPath $normalized
155
+ } else {
156
+ $command = Get-StringProperty -Value $toolInput -Name 'command'
157
+ if ($command) {
158
+ $requiresReadyCheckpoint = Test-ImplementationCommand -Command $command
159
+ } else {
160
+ $requiresReadyCheckpoint = Test-ImplementationDelegation -ToolInput $toolInput
161
+ }
162
+ }
163
+
164
+ if (-not $requiresReadyCheckpoint) {
165
+ return [ordered]@{ decision = 'allow' }
166
+ }
167
+
168
+ if (-not $CheckpointRaw) {
169
+ $CheckpointRaw = Get-CheckpointContent
170
+ }
171
+ try {
172
+ $checkpoint = ConvertFrom-CheckpointJson -Json $CheckpointRaw
173
+ } catch {
174
+ $checkpoint = $null
175
+ }
176
+
177
+ if (Test-OrchestrationReady -Payload $checkpoint) {
178
+ return [ordered]@{ decision = 'allow' }
179
+ }
180
+ return [ordered]@{
181
+ decision = 'block'
182
+ reason = 'PREIMPLEMENTATION_GATE_BLOCKED: Implementation operations require artifacts/orchestration/orchestrator-state.json to contain issue number, feature folder, route metadata, lifecycle readiness, and checkpoint state before implementation begins.'
183
+ }
184
+ }
185
+
186
+ if ($MyInvocation.InvocationName -eq '.') {
187
+ return
188
+ }
189
+
190
+ try {
191
+ $decision = Invoke-OrchestrationPreimplementationGateDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
192
+ } catch {
193
+ Write-Error $_
194
+ exit 1
195
+ }
196
+
197
+ $decision | ConvertTo-Json -Compress | Write-Output
198
+ exit 0
@@ -14,18 +14,39 @@
14
14
  3. gh pr create --body-file artifacts/pr_body_<N>.md
15
15
  (or gh pr edit --body-file ...)
16
16
 
17
- Three block cases:
18
- Case A - gh pr create with --body (inline, no --body-file): blocked.
17
+ Block cases:
18
+ Case A - gh pr create or gh pr edit with --body (inline, no --body-file): blocked.
19
19
  Case B - gh pr create with neither --body nor --body-file: blocked.
20
20
  Case C - gh pr create or gh pr edit with --body-file but context artifact absent: blocked.
21
+ Case D - --body-file present, context artifact present, but the authorization sentinel
22
+ artifacts/pr_author_authorization.json is absent or empty: blocked
23
+ (PR_AGENT_AUTHORIZATION_MISSING).
24
+ Malformed - sentinel present but not valid JSON, or issued_at missing/unparseable: blocked
25
+ (PR_AGENT_AUTHORIZATION_MALFORMED).
26
+ Case E - sentinel valid JSON but issued_by != "pr-author": blocked
27
+ (PR_AGENT_AUTHORIZATION_INVALID).
28
+ Case F - sentinel issued_by == "pr-author" but elapsed time since issued_at exceeds
29
+ ttl_seconds: blocked (PR_AGENT_AUTHORIZATION_EXPIRED).
30
+
31
+ Authorization sentinel decision order on the --body-file-with-context path:
32
+ missing -> malformed -> invalid issuer -> expired -> allow.
21
33
 
22
34
  .NOTES
23
35
  Compatible with PowerShell 7+. No external module dependencies.
36
+
37
+ Enforcement strength: the authorization sentinel is a policy guardrail, not a cryptographic
38
+ or security control. Any actor with Write access to artifacts/ can forge
39
+ artifacts/pr_author_authorization.json, because all agents share the same filesystem and the
40
+ runtime exposes no native agent-identity signal at Bash PreToolUse time. The mechanism prevents
41
+ accidental bypass and requires a deliberate, documented act to circumvent. It MUST NOT be
42
+ described as tamper-proof or as a security boundary.
24
43
  #>
25
44
  [CmdletBinding()]
26
45
  param()
27
46
 
28
47
  $script:PrContextArtifactPath = 'artifacts/pr_context.summary.txt'
48
+ $script:PrAuthorAuthorizationPath = 'artifacts/pr_author_authorization.json'
49
+ $script:PrAuthorAuthorizationTtlSeconds = 120
29
50
 
30
51
  function Get-PrContextArtifactExistence {
31
52
  <#
@@ -41,14 +62,126 @@ function Get-PrContextArtifactExistence {
41
62
  return [bool](Test-Path -LiteralPath $script:PrContextArtifactPath)
42
63
  }
43
64
 
65
+ function Get-PrAuthorAuthorizationContent {
66
+ <#
67
+ .SYNOPSIS
68
+ Read the raw text of the authorization sentinel. Tests mock this function (read seam).
69
+ .DESCRIPTION
70
+ Returns the raw text content of artifacts/pr_author_authorization.json, or $null when the
71
+ sentinel file is absent. This is the injectable boundary for sentinel content in tests; no
72
+ test writes the sentinel file to disk.
73
+ .OUTPUTS
74
+ System.String or $null
75
+ #>
76
+ [CmdletBinding()]
77
+ [OutputType([string])]
78
+ param()
79
+
80
+ if (-not (Test-Path -LiteralPath $script:PrAuthorAuthorizationPath)) {
81
+ return $null
82
+ }
83
+
84
+ return (Get-Content -LiteralPath $script:PrAuthorAuthorizationPath -Raw)
85
+ }
86
+
87
+ function Get-CurrentDateTimeUtc {
88
+ <#
89
+ .SYNOPSIS
90
+ Return the current UTC time. Tests mock this function (clock seam) for time-travel scenarios.
91
+ .OUTPUTS
92
+ System.DateTime
93
+ #>
94
+ [CmdletBinding()]
95
+ [OutputType([datetime])]
96
+ param()
97
+
98
+ return [DateTime]::UtcNow
99
+ }
100
+
101
+ function Test-PrAuthorAuthorization {
102
+ <#
103
+ .SYNOPSIS
104
+ Validate the authorization sentinel and return a block-reason string, or $null when authorized.
105
+ .DESCRIPTION
106
+ Reads the sentinel via Get-PrAuthorAuthorizationContent and computes elapsed time via
107
+ Get-CurrentDateTimeUtc. Applies the decision order missing -> malformed -> invalid issuer ->
108
+ expired -> allow (spec FR-2 step 3):
109
+ - $null/empty contents -> PR_AGENT_AUTHORIZATION_MISSING
110
+ - not valid JSON, or issued_at
111
+ missing/unparseable -> PR_AGENT_AUTHORIZATION_MALFORMED
112
+ - issued_by != "pr-author" -> PR_AGENT_AUTHORIZATION_INVALID
113
+ - elapsed seconds > ttl_seconds -> PR_AGENT_AUTHORIZATION_EXPIRED
114
+ - all checks pass -> $null (allow)
115
+ The ttl_seconds value defaults to the named constant when absent from the sentinel.
116
+ This is a policy guardrail, not a cryptographic control; the sentinel is forgeable by any
117
+ actor with Write access to artifacts/.
118
+ .OUTPUTS
119
+ System.String or $null
120
+ #>
121
+ [CmdletBinding()]
122
+ [OutputType([string])]
123
+ param()
124
+
125
+ $contents = Get-PrAuthorAuthorizationContent
126
+
127
+ if ([string]::IsNullOrWhiteSpace($contents)) {
128
+ return "PR_AGENT_AUTHORIZATION_MISSING: ``$script:PrAuthorAuthorizationPath`` is absent or empty. The pr-author agent must write the authorization sentinel immediately before issuing ``gh pr create``/``gh pr edit --body*``."
129
+ }
130
+
131
+ try {
132
+ $sentinel = $contents | ConvertFrom-Json -ErrorAction Stop
133
+ } catch {
134
+ return "PR_AGENT_AUTHORIZATION_MALFORMED: ``$script:PrAuthorAuthorizationPath`` is not valid JSON. The pr-author agent must write a well-formed sentinel with ``issued_by``, ``issued_at``, and ``ttl_seconds``."
135
+ }
136
+
137
+ $issuedAtRaw = $sentinel.issued_at
138
+ if ([string]::IsNullOrWhiteSpace([string]$issuedAtRaw)) {
139
+ return "PR_AGENT_AUTHORIZATION_MALFORMED: ``$script:PrAuthorAuthorizationPath`` is missing ``issued_at``. The pr-author agent must record a UTC ISO-8601 ``issued_at`` timestamp."
140
+ }
141
+
142
+ $issuedAt = [DateTime]::MinValue
143
+ $parsed = [DateTime]::TryParse(
144
+ [string]$issuedAtRaw,
145
+ [System.Globalization.CultureInfo]::InvariantCulture,
146
+ [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal,
147
+ [ref] $issuedAt)
148
+ if (-not $parsed) {
149
+ return "PR_AGENT_AUTHORIZATION_MALFORMED: ``$script:PrAuthorAuthorizationPath`` has an unparseable ``issued_at``. The pr-author agent must record a UTC ISO-8601 ``issued_at`` timestamp."
150
+ }
151
+
152
+ if ($sentinel.issued_by -ne 'pr-author') {
153
+ return "PR_AGENT_AUTHORIZATION_INVALID: ``$script:PrAuthorAuthorizationPath`` ``issued_by`` is not ``pr-author``. Only the pr-author agent may authorize PR creation or body edits."
154
+ }
155
+
156
+ $ttlSeconds = $script:PrAuthorAuthorizationTtlSeconds
157
+ if ($null -ne $sentinel.ttl_seconds) {
158
+ $candidateTtl = 0
159
+ if ([int]::TryParse([string]$sentinel.ttl_seconds, [ref] $candidateTtl)) {
160
+ $ttlSeconds = $candidateTtl
161
+ }
162
+ }
163
+
164
+ $elapsedSeconds = ((Get-CurrentDateTimeUtc) - $issuedAt).TotalSeconds
165
+ if ($elapsedSeconds -gt $ttlSeconds) {
166
+ return "PR_AGENT_AUTHORIZATION_EXPIRED: ``$script:PrAuthorAuthorizationPath`` issued $([math]::Round($elapsedSeconds)) s ago, exceeding the ${ttlSeconds}s TTL. The pr-author agent must write a fresh sentinel immediately before the ``gh`` command."
167
+ }
168
+
169
+ return $null
170
+ }
171
+
44
172
  function Get-PrAuthorBypassReason {
45
173
  <#
46
174
  .SYNOPSIS
47
175
  Inspect the command text and return a block reason string, or $null when the command is allowed.
48
176
  .DESCRIPTION
49
- Returns PR_AUTHOR_SKILL_BLOCKED when gh pr create is run with --body (inline) or with no body
50
- flag at all. Returns PR_CONTEXT_MISSING when --body-file is present but the context artifact
51
- does not exist on disk. Returns $null for all allowed patterns.
177
+ Returns PR_AUTHOR_SKILL_BLOCKED when gh pr create or gh pr edit is run with --body (inline,
178
+ no --body-file), or when gh pr create is run with no body flag at all. Returns
179
+ PR_CONTEXT_MISSING when --body-file is present but the context artifact
180
+ does not exist on disk. When --body-file is present and the context artifact exists, evaluates
181
+ the authorization sentinel via Test-PrAuthorAuthorization and returns its block reason
182
+ (PR_AGENT_AUTHORIZATION_*) when authorization fails. Returns $null for all allowed patterns.
183
+ Cases A, B, and C are evaluated first and unchanged; the sentinel check only extends the
184
+ previously-allowed --body-file-with-context path.
52
185
  .PARAMETER CommandText
53
186
  The Bash command text extracted from CLAUDE_TOOL_INPUT.
54
187
  .PARAMETER ContextExists
@@ -77,12 +210,13 @@ function Get-PrAuthorBypassReason {
77
210
  $hasBodyFile = $CommandText -match '(?i)--body-file\b'
78
211
  $hasInlineBody = $CommandText -match '(?i)--body(?!-file)\b'
79
212
 
80
- if ($isPrCreate) {
81
- # Case A: gh pr create with inline --body (not --body-file).
82
- if ($hasInlineBody -and -not $hasBodyFile) {
83
- return "PR_AUTHOR_SKILL_BLOCKED: ``gh pr create`` must use ``--body-file`` with a file produced by the pr-author skill from ``$script:PrContextArtifactPath``. Run ``mcp__drm-copilot__collect_pr_context`` to generate the context file, apply the pr-author skill to produce ``artifacts/pr_body_<N>.md``, then pass that file via ``--body-file``."
84
- }
213
+ # Case A: gh pr create OR gh pr edit with inline --body (not --body-file). Evaluated before the
214
+ # gh pr edit no-body allow short-circuit so inline-body edits are blocked, not allowed.
215
+ if (($isPrCreate -or $isPrEdit) -and $hasInlineBody -and -not $hasBodyFile) {
216
+ return "PR_AUTHOR_SKILL_BLOCKED: ``gh pr create`` and ``gh pr edit`` must use ``--body-file`` with a file produced by the pr-author skill from ``$script:PrContextArtifactPath``. Run ``mcp__drm-copilot__collect_pr_context`` to generate the context file, apply the pr-author skill to produce ``artifacts/pr_body_<N>.md``, then pass that file via ``--body-file``."
217
+ }
85
218
 
219
+ if ($isPrCreate) {
86
220
  # Case B: gh pr create with no body flag at all.
87
221
  if (-not $hasInlineBody -and -not $hasBodyFile) {
88
222
  return "PR_AUTHOR_SKILL_BLOCKED: New PRs require ``--body-file``. Run ``mcp__drm-copilot__collect_pr_context`` to generate ``$script:PrContextArtifactPath``, apply the pr-author skill to produce ``artifacts/pr_body_<N>.md``, then pass that file via ``--body-file``."
@@ -101,6 +235,15 @@ function Get-PrAuthorBypassReason {
101
235
  return "PR_CONTEXT_MISSING: ``$script:PrContextArtifactPath`` is absent. Run ``mcp__drm-copilot__collect_pr_context`` before creating or editing the PR body."
102
236
  }
103
237
 
238
+ # Cases D/E/F and malformed: --body-file present and context artifact exists; verify the
239
+ # authorization sentinel. This extends, and does not replace, the previously-allowed path.
240
+ if ($hasBodyFile -and $ContextExists) {
241
+ $authorizationReason = Test-PrAuthorAuthorization
242
+ if ($authorizationReason) {
243
+ return $authorizationReason
244
+ }
245
+ }
246
+
104
247
  return $null
105
248
  }
106
249
 
@@ -141,6 +141,58 @@ function Test-HumanInteractionShape {
141
141
  return @{ Ok = $true; Message = $null }
142
142
  }
143
143
 
144
+ function Invoke-RoutingContractValidation {
145
+ <#
146
+ .SYNOPSIS
147
+ Runs the authoritative Python routing-contract validator against the
148
+ on-disk checkpoint and reports whether it emitted errors.
149
+ .DESCRIPTION
150
+ Invokes the validator through an injectable subprocess scriptblock seam.
151
+ The default Invoker runs the authoritative Python CLI:
152
+ python -m scripts.dev_tools.validate_orchestration_artifacts \
153
+ orchestrator-state <CheckpointPath> --require-complete
154
+ Tests inject a mock scriptblock so no Python process runs. The function
155
+ does not reimplement routing logic; it delegates to the Python validator.
156
+
157
+ Returns a hashtable with keys:
158
+ - HasErrors: $true when the validator reported a non-zero exit or
159
+ produced any error text; $false when clean.
160
+ - ErrorText: the validator's combined output text (empty on success).
161
+ #>
162
+ [CmdletBinding()]
163
+ [OutputType([hashtable])]
164
+ param(
165
+ [Parameter(Mandatory = $true)]
166
+ [string] $CheckpointPath,
167
+
168
+ [Parameter(Mandatory = $false)]
169
+ [scriptblock] $Invoker = {
170
+ param($Path)
171
+ $output = & python -m scripts.dev_tools.validate_orchestration_artifacts `
172
+ orchestrator-state $Path --require-complete 2>&1
173
+ [pscustomobject]@{
174
+ ExitCode = $LASTEXITCODE
175
+ Output = ($output | Out-String)
176
+ }
177
+ }
178
+ )
179
+
180
+ $result = & $Invoker $CheckpointPath
181
+ $exitCode = 0
182
+ if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'ExitCode')) {
183
+ $exitCode = [int]$result.ExitCode
184
+ }
185
+ $outputText = ''
186
+ if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Output')) {
187
+ $outputText = ([string]$result.Output).Trim()
188
+ }
189
+
190
+ # The validator signals a routing-contract failure either through a non-zero
191
+ # exit code or through emitted error text; either condition blocks DONE.
192
+ $hasErrors = ($exitCode -ne 0) -or (-not [string]::IsNullOrWhiteSpace($outputText))
193
+ return @{ HasErrors = $hasErrors; ErrorText = $outputText }
194
+ }
195
+
144
196
  function Invoke-OrchestratorOutputValidation {
145
197
  <#
146
198
  .SYNOPSIS
@@ -154,7 +206,10 @@ function Invoke-OrchestratorOutputValidation {
154
206
  [OutputType([hashtable])]
155
207
  param(
156
208
  [string] $RawPayload,
157
- [string] $CheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
209
+ [string] $CheckpointPath = 'artifacts/orchestration/orchestrator-state.json',
210
+
211
+ [Parameter(Mandatory = $false)]
212
+ [scriptblock] $RoutingInvoker
158
213
  )
159
214
 
160
215
  if ([string]::IsNullOrWhiteSpace($RawPayload)) {
@@ -217,6 +272,18 @@ function Invoke-OrchestratorOutputValidation {
217
272
  return @{ Ok = $false; Message = $hiResult.Message }
218
273
  }
219
274
 
275
+ # Delegate to the authoritative Python routing-contract validator. The
276
+ # optional RoutingInvoker seam lets tests inject a mock; the default seam
277
+ # produces the real subprocess call.
278
+ $routingArgs = @{ CheckpointPath = $CheckpointPath }
279
+ if ($PSBoundParameters.ContainsKey('RoutingInvoker') -and $null -ne $RoutingInvoker) {
280
+ $routingArgs['Invoker'] = $RoutingInvoker
281
+ }
282
+ $routingResult = Invoke-RoutingContractValidation @routingArgs
283
+ if ($routingResult.HasErrors) {
284
+ return @{ Ok = $false; Message = "ROUTING_CONTRACT_BLOCKED: $($routingResult.ErrorText)" }
285
+ }
286
+
220
287
  return @{ Ok = $true; Message = $null }
221
288
  }
222
289