@danmoisan/drm-copilot-mcp 1.0.1 → 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 (69) hide show
  1. package/out/mcp-server.js +896 -202
  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/commit-message.md +42 -0
  6. package/resources/claude-customizations/.claude/agents/csharp-typed-engineer.md +1 -0
  7. package/resources/claude-customizations/.claude/agents/epic-orchestrator.md +128 -0
  8. package/resources/claude-customizations/.claude/agents/epic-review.md +1 -0
  9. package/resources/claude-customizations/.claude/agents/feature-review.md +1 -0
  10. package/resources/claude-customizations/.claude/agents/human-exception-runbook.md +48 -0
  11. package/resources/claude-customizations/.claude/agents/orchestrator.md +80 -2
  12. package/resources/claude-customizations/.claude/agents/powershell-typed-engineer.md +1 -0
  13. package/resources/claude-customizations/.claude/agents/pr-author.md +37 -21
  14. package/resources/claude-customizations/.claude/agents/prd-feature.md +1 -0
  15. package/resources/claude-customizations/.claude/agents/python-typed-engineer.md +1 -0
  16. package/resources/claude-customizations/.claude/agents/staged-review.md +1 -0
  17. package/resources/claude-customizations/.claude/agents/status-updater.md +1 -0
  18. package/resources/claude-customizations/.claude/agents/typescript-engineer.md +1 -0
  19. package/resources/claude-customizations/.claude/hooks/check-powershell-test-purity.ps1 +103 -65
  20. package/resources/claude-customizations/.claude/hooks/check-python-test-purity.ps1 +12 -9
  21. package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +24 -16
  22. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +19 -13
  23. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +304 -0
  24. package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +304 -0
  25. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +237 -0
  26. package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +16 -12
  27. package/resources/claude-customizations/.claude/hooks/enforce-feature-folder-order.ps1 +14 -10
  28. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +182 -0
  29. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +35 -8
  30. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +17 -14
  31. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +104 -0
  32. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +268 -93
  33. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +19 -11
  34. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +28 -6
  35. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +17 -14
  36. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +146 -36
  37. package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +27 -7
  38. package/resources/claude-customizations/.claude/rules/orchestrator-state.md +53 -0
  39. package/resources/claude-customizations/.claude/settings.json +38 -1
  40. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +252 -0
  41. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +59 -13
  42. package/resources/claude-customizations/.claude/skills/pr-author/SKILL.md +28 -0
  43. package/resources/claude-customizations/.claude/skills/remediation-handoff-atomic-planner/SKILL.md +22 -18
  44. package/resources/claude-customizations/pack-manifests/core.json +9 -0
  45. package/resources/codex-and-agents-customizations/.agents/skills/feature-promotion-lifecycle/SKILL.md +8 -2
  46. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +24 -3
  47. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-workflow/SKILL.md +13 -0
  48. package/resources/codex-and-agents-customizations/.agents/skills/remediation-handoff-atomic-planner/SKILL.md +1 -1
  49. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp/SKILL.md +65 -0
  50. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +79 -0
  51. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/invoke-csharp-engineer/SKILL.md +66 -0
  52. package/resources/codex-and-agents-customizations/.codex/agents/orchestrator.toml +15 -14
  53. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -25
  54. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-completion-consistency.ps1 +144 -28
  55. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-completion-helpers.ps1 +163 -0
  56. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-pr-author-skill.ps1 +257 -93
  57. package/resources/codex-and-agents-customizations/.codex-variants/csharp-legacy/agents/csharp-typed-engineer.toml +98 -0
  58. package/resources/codex-and-agents-customizations/pack-manifests/core.json +24 -0
  59. package/resources/codex-and-agents-customizations/pack-manifests/csharp-legacy.json +11 -0
  60. package/resources/codex-and-agents-customizations/pack-manifests/csharp-modern.json +10 -0
  61. package/resources/codex-and-agents-customizations/pack-manifests/powershell.json +15 -0
  62. package/resources/codex-and-agents-customizations/pack-manifests/python.json +15 -0
  63. package/resources/codex-and-agents-customizations/pack-manifests/typescript.json +9 -0
  64. package/resources/config/orchestration-routing.json +92 -0
  65. package/resources/customizations/.github/agents/pr-author.agent.md +24 -18
  66. package/resources/icon.png +0 -0
  67. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +11 -0
  68. package/resources/codex-and-agents-customizations/.github/workflows/_validate-orchestrator-state.yml +0 -68
  69. package/resources/codex-and-agents-customizations/.github/workflows/validate-orchestrator-state.yml +0 -15
@@ -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
+ }
@@ -13,7 +13,9 @@
13
13
 
14
14
  Required sequence:
15
15
  1. mcp__drm-copilot__collect_pr_context writes artifacts/pr_context.summary.txt
16
- 2. pr-author skill reads that file and writes artifacts/pr_body_<N>.md
16
+ 2. pr-author skill produces the body text; the pr-author agent writes
17
+ artifacts/pr_body_<N>.md and a sibling integrity receipt
18
+ artifacts/pr_body_<N>.receipt.json
17
19
  3. gh pr create --body-file artifacts/pr_body_<N>.md
18
20
  (or gh pr edit --body-file ...)
19
21
 
@@ -21,35 +23,71 @@
21
23
  Case A - gh pr create or gh pr edit with --body (inline, no --body-file): blocked.
22
24
  Case B - gh pr create with neither --body nor --body-file: blocked.
23
25
  Case C - gh pr create or gh pr edit with --body-file but context artifact absent: blocked.
24
- Case D - --body-file present, context artifact present, but the authorization sentinel
25
- artifacts/pr_author_authorization.json is absent or empty: blocked
26
- (PR_AGENT_AUTHORIZATION_MISSING).
27
- Malformed - sentinel present but not valid JSON, or issued_at missing/unparseable: blocked
28
- (PR_AGENT_AUTHORIZATION_MALFORMED).
29
- Case E - sentinel valid JSON but issued_by != "pr-author": blocked
30
- (PR_AGENT_AUTHORIZATION_INVALID).
31
- Case F - sentinel issued_by == "pr-author" but elapsed time since issued_at exceeds
32
- ttl_seconds: blocked (PR_AGENT_AUTHORIZATION_EXPIRED).
33
-
34
- Authorization sentinel decision order on the --body-file-with-context path:
35
- missing -> malformed -> invalid issuer -> expired -> allow.
26
+ Preflight - --body-file/context present: orchestrator-state checkpoint must pass
27
+ --require-pr-creation-ready before receipt verification runs, else blocked.
28
+ Receipt - preflight passed: the SHA-256 receipt is verified in five ordered checks
29
+ (Section below). The first failing check blocks.
30
+
31
+ Receipt verification decision order on the --body-file-with-context path:
32
+ PR_BODY_PATH_NONCANONICAL -> PR_AUTHOR_RECEIPT_MISSING -> PR_AUTHOR_RECEIPT_NUMBER_MISMATCH
33
+ -> PR_AUTHOR_RECEIPT_HASH_MISMATCH -> PR_AUTHOR_RECEIPT_STALE -> allow.
36
34
 
37
35
  .NOTES
38
36
  Compatible with PowerShell 7+. No external module dependencies.
39
37
 
40
- Enforcement strength: the authorization sentinel is a policy guardrail, not a cryptographic
41
- or security control. Any actor with Write access to artifacts/ can forge
42
- artifacts/pr_author_authorization.json, because all agents share the same filesystem and the
43
- runtime exposes no native agent-identity signal at Bash PreToolUse time. The mechanism prevents
44
- accidental bypass and requires a deliberate, documented act to circumvent. It MUST NOT be
45
- described as tamper-proof or as a security boundary.
38
+ Enforcement strength: the SHA-256 receipt is a policy-level integrity check that binds the
39
+ PR body bytes to the receipt the pr-author agent wrote. It is not a cryptographic or security
40
+ boundary: any actor with Write access to artifacts/ can replace both the body file and the
41
+ receipt together, because all agents share the same filesystem and the runtime exposes no
42
+ native agent-identity signal at Bash PreToolUse time. The mechanism prevents accidental bypass
43
+ and requires a deliberate, documented act to circumvent. It MUST NOT be described as
44
+ tamper-proof or as a security boundary.
46
45
  #>
47
46
  [CmdletBinding()]
48
47
  param()
49
48
 
50
49
  $script:PrContextArtifactPath = 'artifacts/pr_context.summary.txt'
51
- $script:PrAuthorAuthorizationPath = 'artifacts/pr_author_authorization.json'
52
- $script:PrAuthorAuthorizationTtlSeconds = 120
50
+ $script:OrchestratorStateCheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
51
+
52
+ function Invoke-OrchestratorStatePreflight {
53
+ <#
54
+ .SYNOPSIS
55
+ Runs the orchestrator-state validator against the checkpoint and reports pass/fail.
56
+ .DESCRIPTION
57
+ Mirrors Invoke-RoutingContractValidation (.codex/hooks/validate-orchestrator-output.ps1):
58
+ an injectable subprocess scriptblock seam defaults to ``python -m
59
+ scripts.dev_tools.validate_orchestration_artifacts orchestrator-state <CheckpointPath>
60
+ --require-pr-creation-ready``. A missing checkpoint or validation failure surfaces via the
61
+ validator's exit/stderr text (pre-PR-creation readiness, not full completion).
62
+ .OUTPUTS
63
+ System.Collections.Hashtable with keys HasErrors (bool) and ErrorText (string).
64
+ #>
65
+ [CmdletBinding()]
66
+ [OutputType([hashtable])]
67
+ param(
68
+ [Parameter(Mandatory = $false)]
69
+ [string] $CheckpointPath = $script:OrchestratorStateCheckpointPath,
70
+
71
+ [Parameter(Mandatory = $false)]
72
+ [scriptblock] $Invoker = {
73
+ param($Path)
74
+ $output = & python -m scripts.dev_tools.validate_orchestration_artifacts `
75
+ orchestrator-state $Path --require-pr-creation-ready 2>&1
76
+ [pscustomobject]@{
77
+ ExitCode = $LASTEXITCODE
78
+ Output = ($output | Out-String)
79
+ }
80
+ }
81
+ )
82
+
83
+ $result = & $Invoker $CheckpointPath
84
+ $exitCode = 0
85
+ if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'ExitCode')) { $exitCode = [int]$result.ExitCode }
86
+ $outputText = ''
87
+ if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Output')) { $outputText = ([string]$result.Output).Trim() }
88
+
89
+ return @{ HasErrors = ($exitCode -ne 0); ErrorText = $outputText }
90
+ }
53
91
 
54
92
  function Get-PrContextArtifactExistence {
55
93
  <#
@@ -65,108 +103,180 @@ function Get-PrContextArtifactExistence {
65
103
  return [bool](Test-Path -LiteralPath $script:PrContextArtifactPath)
66
104
  }
67
105
 
68
- function Get-PrAuthorAuthorizationContent {
106
+ function Get-PrBodyFileBytes {
107
+ <#
108
+ .SYNOPSIS
109
+ Read the raw bytes of the PR body file. Tests mock this function (read seam).
110
+ .DESCRIPTION
111
+ Returns the byte content of the supplied body-file path, or $null when the file is absent.
112
+ This is the injectable boundary for body-file bytes in tests; no test writes the body file
113
+ to disk. The bytes are hashed inline by the receipt verification function.
114
+ .PARAMETER BodyFilePath
115
+ The relative path to the PR body file (for example artifacts/pr_body_5.md).
116
+ .OUTPUTS
117
+ System.Byte[] or $null
118
+ #>
119
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'The plural noun names the byte-array return; the seam name is fixed by the receipt contract.')]
120
+ [CmdletBinding()]
121
+ [OutputType([byte[]])]
122
+ param(
123
+ [Parameter(Mandatory)]
124
+ [string] $BodyFilePath
125
+ )
126
+
127
+ if (-not (Test-Path -LiteralPath $BodyFilePath)) {
128
+ return $null
129
+ }
130
+
131
+ return [System.IO.File]::ReadAllBytes($BodyFilePath)
132
+ }
133
+
134
+ function Get-PrAuthorReceiptContent {
69
135
  <#
70
136
  .SYNOPSIS
71
- Read the raw text of the authorization sentinel. Tests mock this function (read seam).
137
+ Read the raw JSON text of the PR body receipt. Tests mock this function (read seam).
72
138
  .DESCRIPTION
73
- Returns the raw text content of artifacts/pr_author_authorization.json, or $null when the
74
- sentinel file is absent. This is the injectable boundary for sentinel content in tests; no
75
- test writes the sentinel file to disk.
139
+ Returns the raw text content of the sibling receipt file artifacts/pr_body_<N>.receipt.json,
140
+ or $null when the receipt file is absent. This is the injectable boundary for receipt
141
+ content in tests; no test writes the receipt file to disk.
142
+ .PARAMETER ReceiptFilePath
143
+ The relative path to the receipt file (for example artifacts/pr_body_5.receipt.json).
76
144
  .OUTPUTS
77
145
  System.String or $null
78
146
  #>
79
147
  [CmdletBinding()]
80
148
  [OutputType([string])]
81
- param()
149
+ param(
150
+ [Parameter(Mandatory)]
151
+ [string] $ReceiptFilePath
152
+ )
82
153
 
83
- if (-not (Test-Path -LiteralPath $script:PrAuthorAuthorizationPath)) {
154
+ if (-not (Test-Path -LiteralPath $ReceiptFilePath)) {
84
155
  return $null
85
156
  }
86
157
 
87
- return (Get-Content -LiteralPath $script:PrAuthorAuthorizationPath -Raw)
158
+ return (Get-Content -LiteralPath $ReceiptFilePath -Raw)
88
159
  }
89
160
 
90
- function Get-CurrentDateTimeUtc {
161
+ function Get-PrContextSummaryLastWriteUtc {
91
162
  <#
92
163
  .SYNOPSIS
93
- Return the current UTC time. Tests mock this function (clock seam) for time-travel scenarios.
164
+ Return the UTC last-write time of the PR context summary. Tests mock this function (seam).
165
+ .DESCRIPTION
166
+ Returns the LastWriteTimeUtc of artifacts/pr_context.summary.txt, or $null when the file is
167
+ absent. The staleness check compares the receipt's created_at against this value; both are
168
+ artifact metadata, so no wall-clock seam is required.
94
169
  .OUTPUTS
95
- System.DateTime
170
+ System.DateTime or $null
96
171
  #>
97
172
  [CmdletBinding()]
98
173
  [OutputType([datetime])]
99
174
  param()
100
175
 
101
- return [DateTime]::UtcNow
176
+ if (-not (Test-Path -LiteralPath $script:PrContextArtifactPath)) {
177
+ return $null
178
+ }
179
+
180
+ return (Get-Item -LiteralPath $script:PrContextArtifactPath).LastWriteTimeUtc
102
181
  }
103
182
 
104
- function Test-PrAuthorAuthorization {
183
+ function Test-PrAuthorReceiptVerification {
105
184
  <#
106
185
  .SYNOPSIS
107
- Validate the authorization sentinel and return a block-reason string, or $null when authorized.
186
+ Verify the SHA-256 receipt and return a block-reason string, or $null when verified.
108
187
  .DESCRIPTION
109
- Reads the sentinel via Get-PrAuthorAuthorizationContent and computes elapsed time via
110
- Get-CurrentDateTimeUtc. Applies the decision order missing -> malformed -> invalid issuer ->
111
- expired -> allow (spec FR-2 step 3):
112
- - $null/empty contents -> PR_AGENT_AUTHORIZATION_MISSING
113
- - not valid JSON, or issued_at
114
- missing/unparseable -> PR_AGENT_AUTHORIZATION_MALFORMED
115
- - issued_by != "pr-author" -> PR_AGENT_AUTHORIZATION_INVALID
116
- - elapsed seconds > ttl_seconds -> PR_AGENT_AUTHORIZATION_EXPIRED
117
- - all checks pass -> $null (allow)
118
- The ttl_seconds value defaults to the named constant when absent from the sentinel.
119
- This is a policy guardrail, not a cryptographic control; the sentinel is forgeable by any
120
- actor with Write access to artifacts/.
188
+ Runs the five ordered receipt checks on the --body-file-with-context path. Each check is its
189
+ own short-circuiting branch; the first failure returns its reason code:
190
+ 1. PR_BODY_PATH_NONCANONICAL - --body-file path does not match the canonical
191
+ artifacts/pr_body_<N>.md pattern (case-sensitive).
192
+ 2. PR_AUTHOR_RECEIPT_MISSING - sibling artifacts/pr_body_<N>.receipt.json absent.
193
+ 3. PR_AUTHOR_RECEIPT_NUMBER_MISMATCH- receipt.number (integer) != <N> from the path.
194
+ 4. PR_AUTHOR_RECEIPT_HASH_MISMATCH - inline SHA-256 (lowercase hex) of the body bytes
195
+ != receipt.sha256.
196
+ 5. PR_AUTHOR_RECEIPT_STALE - receipt.created_at (UTC) not strictly newer than the
197
+ context summary last-write time.
198
+ Returns $null when all five checks pass (allow). All disk access flows through the three
199
+ injectable seams (Get-PrBodyFileBytes, Get-PrAuthorReceiptContent,
200
+ Get-PrContextSummaryLastWriteUtc); SHA-256 is computed inline. This is a policy-level
201
+ integrity check, not a cryptographic control: any actor with Write access to artifacts/ can
202
+ replace the body file and the receipt together.
203
+ .PARAMETER CommandText
204
+ The Bash command text containing the --body-file argument.
121
205
  .OUTPUTS
122
206
  System.String or $null
123
207
  #>
124
208
  [CmdletBinding()]
125
209
  [OutputType([string])]
126
- param()
210
+ param(
211
+ [Parameter(Mandatory)]
212
+ [string] $CommandText
213
+ )
127
214
 
128
- $contents = Get-PrAuthorAuthorizationContent
215
+ # Check 1: the --body-file argument must match the canonical artifacts/pr_body_<N>.md pattern.
216
+ # The match is case-sensitive (-cmatch) so a non-canonical path is rejected before any read.
217
+ if ($CommandText -cnotmatch '--body-file\s+artifacts/pr_body_(\d+)\.md\b') {
218
+ return "PR_BODY_PATH_NONCANONICAL: ``--body-file`` must reference a canonical ``artifacts/pr_body_<N>.md`` file produced by the pr-author skill. The path supplied does not match ``artifacts/pr_body_<N>.md``."
219
+ }
220
+
221
+ $bodyNumber = [int]$Matches[1]
222
+ $bodyFilePath = "artifacts/pr_body_$bodyNumber.md"
223
+ $receiptFilePath = "artifacts/pr_body_$bodyNumber.receipt.json"
129
224
 
130
- if ([string]::IsNullOrWhiteSpace($contents)) {
131
- 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*``."
225
+ # Check 2: the sibling receipt file must exist (read via the injectable seam).
226
+ $receiptRaw = Get-PrAuthorReceiptContent -ReceiptFilePath $receiptFilePath
227
+ if ([string]::IsNullOrWhiteSpace($receiptRaw)) {
228
+ return "PR_AUTHOR_RECEIPT_MISSING: ``$receiptFilePath`` is absent. The pr-author agent must write the SHA-256 receipt alongside ``$bodyFilePath`` before issuing ``gh pr create``/``gh pr edit --body-file``."
132
229
  }
133
230
 
231
+ # A receipt that is present but not valid JSON cannot be verified; treat it as missing content.
134
232
  try {
135
- $sentinel = $contents | ConvertFrom-Json -ErrorAction Stop
233
+ $receipt = $receiptRaw | ConvertFrom-Json -ErrorAction Stop
136
234
  } catch {
137
- 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``."
235
+ return "PR_AUTHOR_RECEIPT_MISSING: ``$receiptFilePath`` is not valid JSON. The pr-author agent must write a well-formed receipt with ``number``, ``sha256``, and ``created_at``."
138
236
  }
139
237
 
140
- $issuedAtRaw = $sentinel.issued_at
141
- if ([string]::IsNullOrWhiteSpace([string]$issuedAtRaw)) {
142
- return "PR_AGENT_AUTHORIZATION_MALFORMED: ``$script:PrAuthorAuthorizationPath`` is missing ``issued_at``. The pr-author agent must record a UTC ISO-8601 ``issued_at`` timestamp."
238
+ # Check 3: the receipt number must equal the <N> extracted from the canonical path.
239
+ $receiptNumber = $null
240
+ $parsedNumber = 0
241
+ if ([int]::TryParse([string]$receipt.number, [ref] $parsedNumber)) {
242
+ $receiptNumber = $parsedNumber
243
+ }
244
+ if ($receiptNumber -ne $bodyNumber) {
245
+ return "PR_AUTHOR_RECEIPT_NUMBER_MISMATCH: ``$receiptFilePath`` ``number`` ($($receipt.number)) does not equal the body-file number ($bodyNumber). The receipt must bind to ``$bodyFilePath``."
143
246
  }
144
247
 
145
- $issuedAt = [DateTime]::MinValue
146
- $parsed = [DateTime]::TryParse(
147
- [string]$issuedAtRaw,
148
- [System.Globalization.CultureInfo]::InvariantCulture,
149
- [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal,
150
- [ref] $issuedAt)
151
- if (-not $parsed) {
152
- 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."
248
+ # Check 4: the inline SHA-256 (lowercase hex) of the body bytes must equal receipt.sha256.
249
+ $bodyBytes = Get-PrBodyFileBytes -BodyFilePath $bodyFilePath
250
+ if ($null -eq $bodyBytes) {
251
+ return "PR_AUTHOR_RECEIPT_HASH_MISMATCH: ``$bodyFilePath`` could not be read to verify its SHA-256 against ``$receiptFilePath``."
153
252
  }
154
253
 
155
- if ($sentinel.issued_by -ne 'pr-author') {
156
- 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."
254
+ $sha256 = [System.Security.Cryptography.SHA256]::Create()
255
+ try {
256
+ $hashBytes = $sha256.ComputeHash($bodyBytes)
257
+ } finally {
258
+ $sha256.Dispose()
157
259
  }
260
+ $computedHash = ([System.BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
158
261
 
159
- $ttlSeconds = $script:PrAuthorAuthorizationTtlSeconds
160
- if ($null -ne $sentinel.ttl_seconds) {
161
- $candidateTtl = 0
162
- if ([int]::TryParse([string]$sentinel.ttl_seconds, [ref] $candidateTtl)) {
163
- $ttlSeconds = $candidateTtl
164
- }
262
+ if ($computedHash -ne ([string]$receipt.sha256).ToLowerInvariant()) {
263
+ return "PR_AUTHOR_RECEIPT_HASH_MISMATCH: the SHA-256 of ``$bodyFilePath`` does not equal ``sha256`` in ``$receiptFilePath``. The body file was modified after the receipt was written."
264
+ }
265
+
266
+ # Check 5: receipt.created_at (UTC) must be strictly newer than the context summary last-write.
267
+ $createdAt = [DateTime]::MinValue
268
+ $createdParsed = [DateTime]::TryParse(
269
+ [string]$receipt.created_at,
270
+ [System.Globalization.CultureInfo]::InvariantCulture,
271
+ [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal,
272
+ [ref] $createdAt)
273
+ if (-not $createdParsed) {
274
+ return "PR_AUTHOR_RECEIPT_STALE: ``$receiptFilePath`` has a missing or unparseable ``created_at``. The pr-author agent must record a UTC ISO-8601 ``created_at`` strictly newer than ``$script:PrContextArtifactPath``."
165
275
  }
166
276
 
167
- $elapsedSeconds = ((Get-CurrentDateTimeUtc) - $issuedAt).TotalSeconds
168
- if ($elapsedSeconds -gt $ttlSeconds) {
169
- 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."
277
+ $contextLastWrite = Get-PrContextSummaryLastWriteUtc
278
+ if (($null -eq $contextLastWrite) -or ($createdAt -le $contextLastWrite)) {
279
+ return "PR_AUTHOR_RECEIPT_STALE: ``$receiptFilePath`` ``created_at`` is not strictly newer than the last-write time of ``$script:PrContextArtifactPath``. The pr-author agent must regenerate the body and receipt after refreshing the PR context."
170
280
  }
171
281
 
172
282
  return $null
@@ -179,12 +289,12 @@ function Get-PrAuthorBypassReason {
179
289
  .DESCRIPTION
180
290
  Returns PR_AUTHOR_SKILL_BLOCKED when gh pr create or gh pr edit is run with --body (inline,
181
291
  no --body-file), or when gh pr create is run with no body flag at all. Returns
182
- PR_CONTEXT_MISSING when --body-file is present but the context artifact
183
- does not exist on disk. When --body-file is present and the context artifact exists, evaluates
184
- the authorization sentinel via Test-PrAuthorAuthorization and returns its block reason
185
- (PR_AGENT_AUTHORIZATION_*) when authorization fails. Returns $null for all allowed patterns.
186
- Cases A, B, and C are evaluated first and unchanged; the sentinel check only extends the
187
- previously-allowed --body-file-with-context path.
292
+ PR_CONTEXT_MISSING when --body-file is present but the context artifact does not exist on
293
+ disk. When --body-file is present and the context artifact exists, verifies the SHA-256
294
+ receipt via Test-PrAuthorReceiptVerification and returns its block reason
295
+ (PR_BODY_PATH_NONCANONICAL / PR_AUTHOR_RECEIPT_*) when verification fails. Returns $null for
296
+ all allowed patterns. Cases A, B, and C are evaluated first and unchanged; receipt
297
+ verification only extends the previously-allowed --body-file-with-context path.
188
298
  .PARAMETER CommandText
189
299
  The Bash command text extracted from CLAUDE_TOOL_INPUT.
190
300
  .PARAMETER ContextExists
@@ -238,12 +348,25 @@ function Get-PrAuthorBypassReason {
238
348
  return "PR_CONTEXT_MISSING: ``$script:PrContextArtifactPath`` is absent. Run ``mcp__drm-copilot__collect_pr_context`` before creating or editing the PR body."
239
349
  }
240
350
 
241
- # Cases D/E/F and malformed: --body-file present and context artifact exists; verify the
242
- # authorization sentinel. This extends, and does not replace, the previously-allowed path.
351
+ # Orchestrator-state preflight: runs inside this same PreToolUse hook (so it cannot be
352
+ # bypassed by invoking gh pr create/edit directly) before receipt verification.
243
353
  if ($hasBodyFile -and $ContextExists) {
244
- $authorizationReason = Test-PrAuthorAuthorization
245
- if ($authorizationReason) {
246
- return $authorizationReason
354
+ $preflightResult = Invoke-OrchestratorStatePreflight
355
+ if ($preflightResult.HasErrors) {
356
+ $preflightSummary = if ([string]::IsNullOrWhiteSpace($preflightResult.ErrorText)) {
357
+ "checkpoint missing at $script:OrchestratorStateCheckpointPath"
358
+ } else {
359
+ $preflightResult.ErrorText
360
+ }
361
+ return "ORCHESTRATOR_STATE_PREFLIGHT_FAILED: $preflightSummary"
362
+ }
363
+ }
364
+
365
+ # Receipt verification: extends, and does not replace, the previously-allowed path.
366
+ if ($hasBodyFile -and $ContextExists) {
367
+ $receiptReason = Test-PrAuthorReceiptVerification -CommandText $CommandText
368
+ if ($receiptReason) {
369
+ return $receiptReason
247
370
  }
248
371
  }
249
372
 
@@ -268,7 +391,7 @@ function Invoke-PrAuthorSkillDecision {
268
391
  )
269
392
 
270
393
  if (-not $ToolInputRaw) {
271
- return [ordered]@{ decision = 'allow' }
394
+ return Get-PrAuthorSkillAllowDecision
272
395
  }
273
396
 
274
397
  try {
@@ -279,20 +402,61 @@ function Invoke-PrAuthorSkillDecision {
279
402
 
280
403
  $commandText = $toolInput.command
281
404
  if (-not $commandText) {
282
- return [ordered]@{ decision = 'allow' }
405
+ return Get-PrAuthorSkillAllowDecision
283
406
  }
284
407
 
285
408
  $contextExists = Get-PrContextArtifactExistence
286
409
  $reason = Get-PrAuthorBypassReason -CommandText $commandText -ContextExists $contextExists
287
410
 
288
411
  if ($reason) {
289
- return [ordered]@{
290
- decision = 'block'
291
- reason = $reason
412
+ return Get-PrAuthorSkillBlockDecision -Reason $reason
413
+ }
414
+
415
+ return Get-PrAuthorSkillAllowDecision
416
+ }
417
+
418
+ function Get-PrAuthorSkillAllowDecision {
419
+ <#
420
+ .SYNOPSIS
421
+ Construct the PreToolUse allow decision for a permitted Bash command.
422
+ .OUTPUTS
423
+ System.Collections.Specialized.OrderedDictionary
424
+ #>
425
+ [CmdletBinding()]
426
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
427
+ param()
428
+
429
+ return [ordered]@{
430
+ hookSpecificOutput = [ordered]@{
431
+ hookEventName = 'PreToolUse'
432
+ permissionDecision = 'allow'
292
433
  }
293
434
  }
435
+ }
294
436
 
295
- return [ordered]@{ decision = 'allow' }
437
+ function Get-PrAuthorSkillBlockDecision {
438
+ <#
439
+ .SYNOPSIS
440
+ Construct the PreToolUse deny decision for a forbidden Bash command.
441
+ .PARAMETER Reason
442
+ The specific deny reason to surface in the decision.
443
+ .OUTPUTS
444
+ System.Collections.Specialized.OrderedDictionary
445
+ #>
446
+ [CmdletBinding()]
447
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
448
+ param(
449
+ [Parameter(Mandatory)]
450
+ [string] $Reason
451
+ )
452
+
453
+ return [ordered]@{
454
+ hookSpecificOutput = [ordered]@{
455
+ hookEventName = 'PreToolUse'
456
+ permissionDecision = 'deny'
457
+ permissionDecisionReason = $Reason
458
+ }
459
+ }
296
460
  }
297
461
 
298
462
  function Test-PrAuthorBypassRequired {
@@ -331,6 +495,6 @@ try {
331
495
  exit 1
332
496
  }
333
497
 
334
- $decision | ConvertTo-Json -Compress | Write-Output
498
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
335
499
 
336
500
  exit 0