@danmoisan/drm-copilot-mcp 1.1.10 → 1.1.12

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 (82) hide show
  1. package/out/mcp-server.js +3517 -1104
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/epic-orchestrator.md +1 -1
  4. package/resources/claude-customizations/.claude/agents/epic-planner.md +4 -1
  5. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +15 -2
  6. package/resources/claude-customizations/.claude/agents/parallel-planner.md +3 -0
  7. package/resources/claude-customizations/.claude/agents/pr-author.md +15 -0
  8. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate-authorization.ps1 +443 -0
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +82 -51
  10. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +52 -3
  11. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +120 -5
  12. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +127 -0
  13. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +163 -15
  14. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +21 -28
  15. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +103 -6
  16. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +124 -8
  17. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +171 -15
  18. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +50 -10
  19. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +3 -1
  20. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner-helpers.ps1 +332 -0
  21. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +211 -182
  22. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +35 -7
  23. package/resources/claude-customizations/.claude/hooks/hook-command-invocation.ps1 +483 -0
  24. package/resources/claude-customizations/.claude/hooks/hook-command-scanner.ps1 +483 -0
  25. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +218 -6
  26. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +18 -75
  27. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +290 -0
  28. package/resources/claude-customizations/.claude/lib/cleanup-manifest/CleanupWorktreeManifest.psm1 +415 -0
  29. package/resources/claude-customizations/.claude/lib/codex-routing/CodexDeployment.psm1 +1 -0
  30. package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMerge.psm1 +355 -0
  31. package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMergeGrammar.psm1 +318 -0
  32. package/resources/claude-customizations/.claude/lib/project-file-merge/Resolve-MergeableConflict.ps1 +229 -0
  33. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeReadiness.psm1 +176 -0
  34. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeResolution.psm1 +369 -0
  35. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeItemResolution.psm1 +392 -0
  36. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeResolution.psm1 +500 -0
  37. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeTargetResolution.psm1 +347 -0
  38. package/resources/claude-customizations/.claude/rules/orchestrator-state.md +27 -0
  39. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +66 -2
  40. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +323 -16
  41. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +14 -1
  42. package/resources/claude-customizations/.claude/skills/epic-plan/SKILL.md +4 -0
  43. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +46 -1
  44. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +3 -1
  45. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +75 -8
  46. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +6 -0
  47. package/resources/claude-customizations/.claude/skills/powershell-orchestration-state-machine/SKILL.md +29 -1
  48. package/resources/claude-customizations/.claude/skills/pr-author/SKILL.md +15 -0
  49. package/resources/claude-customizations/.claude/skills/quota-throttling/SKILL.md +221 -0
  50. package/resources/claude-customizations/config/blast-radius.json +7 -0
  51. package/resources/claude-customizations/pack-manifests/core.json +16 -0
  52. package/resources/codex-and-agents-customizations/.agents/skills/codex-model-routing/SKILL.md +13 -14
  53. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +41 -0
  54. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-state/SKILL.md +41 -0
  55. package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +26 -0
  56. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  57. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +242 -3
  58. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +67 -12
  59. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-worktree-removal-gate.ps1 +34 -8
  60. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +163 -15
  61. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +6 -1
  62. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +34 -7
  63. package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-invocation.ps1 +483 -0
  64. package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-scanner.ps1 +483 -0
  65. package/resources/codex-and-agents-customizations/.codex/hooks/validate-bash.ps1 +130 -2
  66. package/resources/codex-and-agents-customizations/.codex/scripts/Resolve-CodexDeployment.ps1 +87 -0
  67. package/resources/codex-and-agents-customizations/.codex/scripts/Resolve-CodexTopology.ps1 +96 -0
  68. package/resources/codex-and-agents-customizations/.codex/scripts/codex-routing-cli-common.ps1 +370 -0
  69. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-launch-contract.ps1 +475 -0
  70. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-launch-runtime.ps1 +445 -0
  71. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-persistence-runtime.ps1 +118 -0
  72. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-sandbox-preflight.ps1 +69 -0
  73. package/resources/codex-and-agents-customizations/.codex/scripts/launch-epic-child-wave.ps1 +471 -0
  74. package/resources/codex-and-agents-customizations/.codex/scripts/post-codex-worktree-session.ps1 +280 -0
  75. package/resources/codex-and-agents-customizations/.codex/scripts/resume-epic-child.ps1 +263 -0
  76. package/resources/codex-and-agents-customizations/.codex-variants/csharp-legacy/agents/csharp-typed-engineer.toml +2 -1
  77. package/resources/codex-and-agents-customizations/pack-manifests/core.json +23 -1
  78. package/resources/config/orchestration-handoff-registry.json +138 -0
  79. package/resources/config/orchestration-handoff.schema.json +472 -0
  80. package/resources/lib/codex-routing/CodexDeployment.psm1 +315 -0
  81. package/resources/lib/codex-routing/CodexTopology.psm1 +394 -0
  82. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +64 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmoisan/drm-copilot-mcp",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "description": "Stdio MCP server exposing drm-copilot repo-automation tools.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -134,7 +134,7 @@ full schema enforced by `validate_epic_orchestrator_state_text`, implemented in
134
134
  `epic_feature_folder`,
135
135
  `epic_manifest_path`, `epic_status_doc_path`, `integration_branch`, `completed_steps`, `next_step`,
136
136
  `last_updated`, `current_wave`, `waves[]`, `features[]` (including `merge_status` and the four
137
- lifecycle timestamps), `epic_merge_pr`, and the three receipt arrays
137
+ lifecycle timestamps), `epic_merge_pr`, `epic_issue_num`, `model_routing_receipts[]` (including the `pr-author` receipt recorded before the integration-to-`main` pull request, issue #663), and the three receipt arrays
138
138
  (`delegation_receipts[]`, `skill_receipts[]`, `mcp_call_receipts[]`) populated with the `epic`
139
139
  route's required names from `config/orchestration-routing.json`.
140
140
 
@@ -9,11 +9,13 @@ tools:
9
9
  - Glob
10
10
  - "Write(docs/features/epics/**)"
11
11
  - "Edit(docs/features/epics/**)"
12
+ - "Write(docs/features/potential/**)"
12
13
  - "Write(artifacts/orchestration/**)"
13
14
  - "Edit(artifacts/orchestration/**)"
14
15
  - "Bash(git *)"
15
16
  - "Bash(gh *)"
16
17
  - "mcp__drm-copilot__validate_orchestration_artifacts"
18
+ - "mcp__drm-copilot__potential_to_issue"
17
19
  skills:
18
20
  - policy-compliance-order
19
21
  - epic-plan
@@ -102,10 +104,11 @@ the prepared epic, per the template in the `epic-plan` skill.
102
104
  ## Checkpoint Persistence
103
105
 
104
106
  Update `artifacts/orchestration/epic-planner-state.json` after every completed step with:
105
- `objective`, `epic_feature_folder`, `epic_manifest_path`, `integration_branch`,
107
+ `objective`, `epic_feature_folder`, `epic_manifest_path`, `integration_branch`, `epic_issue_num`,
106
108
  `epic_worthiness` (`{verdict, rationale}`), `features[]` (per-feature `issue_num`,
107
109
  `feature_folder`, `depends_on`, `wave`, `complexity_band`, `preparation_status`, `plan_path`,
108
110
  `preflight_status`), `kickoff_prompt_path`, `completed_steps`, `next_step`, and `last_updated`.
111
+ `epic_issue_num` is the epic-level issue `epic-planner` promotes at planning time through `mcp__drm-copilot__potential_to_issue` with `promotion_type: epic` (issue #663; see `## Epic-Level Issue Promotion` in the `epic-plan` skill).
109
112
 
110
113
  ## Completion Requirements
111
114
 
@@ -18,6 +18,10 @@ tools:
18
18
  - "Bash(bash .claude/lib/bash/compute-cohorts.sh*)"
19
19
  - "Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"
20
20
  - "Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"
21
+ - "Bash(pwsh -NoProfile -File .claude/lib/project-file-merge/Resolve-MergeableConflict.ps1*)"
22
+ - "Bash(dotnet tool restore*)"
23
+ - "Bash(dotnet csharpier check *)"
24
+ - "Bash(dotnet build *)"
21
25
  - "mcp__drm-copilot__collect_pr_context"
22
26
  - "mcp__drm-copilot__validate_orchestration_artifacts"
23
27
  skills:
@@ -222,12 +226,21 @@ and `mcp_call_receipts[]` populated with the `parallel` route's required names f
222
226
 
223
227
  The checkpoint schema is owned by F3 and is defined once in
224
228
  `.claude/rules/parallel-orchestration.md`, enforced by
225
- `scripts/dev_tools/validate_parallel_orchestrator_state.py`. You consume that schema and add no
226
- field to it. The `parallel-orchestrate` skill's `## Parallel-Level Checkpoint` section carries the
229
+ `scripts/dev_tools/validate_parallel_orchestrator_state.py`. You consume that schema and
230
+ add no field to it that the rule file does not declare.
231
+ `mergeable_conflicts_resolved` is the one declared optional item field.
232
+ The `parallel-orchestrate` skill's `## Parallel-Level Checkpoint` section carries the
227
233
  full enumeration, the `merge_status` transition chain you write, and the arrays that are read-only
228
234
  to you. Validate through `mcp__drm-copilot__validate_orchestration_artifacts` with
229
235
  `artifact_type: "parallel-orchestrator-state"`.
230
236
 
237
+ You run the project-file merge step of the `parallel-orchestrate` skill yourself rather than
238
+ delegating it. The four entries added to your `tools:` list above for the resolution script and
239
+ for the three .NET verification commands are scoped to that step and to no other, and they are the
240
+ only reason those commands appear in your allowlist at all. That step's one optional item field is
241
+ `mergeable_conflicts_resolved`, declared in the rule file and appended to an item after a
242
+ parent-side resolution.
243
+
231
244
  ## Documentation Maintenance
232
245
 
233
246
  Maintain `docs/features/parallel/<slug>/parallel-status.md` as a human-readable projection of the
@@ -160,6 +160,9 @@ The facade exports `Get-PlanPaths`, `Get-BlastRadius`, `Get-BlastRadiusFromObser
160
160
  `Test-BlastRadius`, and `Test-BlastRadiusConflict`. Its truth table is
161
161
  `config/blast-radius.json`, which push-down publishes alongside `.claude`.
162
162
 
163
+ `Test-BlastRadiusConflict` reads that table's optional `mergeable_paths` list and contributes no
164
+ `path_overlap` edge for a path matching it, while the path stays in the declared radius.
165
+
163
166
  **Cohort seeding and concurrency batching — bash entry points.** The bash library is granted as
164
167
  four entry-point-specific allowlist entries — `"Bash(bash .claude/lib/bash/compute-cohorts.sh*)"`,
165
168
  `"Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"`,
@@ -92,3 +92,18 @@ deliberate, documented act to circumvent. It is not tamper-proof and is not a se
92
92
  Repository tone is defined in `CLAUDE.md` and `.claude/rules/tonality.md`. Reference only files
93
93
  listed under "Additional context files" in the PR-context bundle; do not cite or summarize files
94
94
  outside that enumeration. Do not invent issue or PR numbers.
95
+
96
+ ## Required `--head` on `gh pr create` (issue #687)
97
+
98
+ Every `gh pr create` command this agent issues MUST carry an explicit
99
+ `--head <branch>` naming the branch the pull request is opened from.
100
+
101
+ This is what makes the call's target derivable. `enforce-pr-author-skill.ps1` resolves the
102
+ worktree the call pertains to from the command text, and validates the orchestrator checkpoint
103
+ belonging to that worktree. Without `--head`, `gh pr create` infers its head branch from the
104
+ current checkout and the command carries no signal at all, so the gate denies with
105
+ `TARGET_WORKTREE_NOT_DERIVABLE` rather than validating against whichever checkpoint occupies the
106
+ session root, which in a parallel or epic topology belongs to a different item.
107
+
108
+ Pass `--head` even when the branch is the current one: the gate resolves it to the session root
109
+ and behaves exactly as before, and the command stays correct if it is ever replayed elsewhere.
@@ -0,0 +1,443 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Dot-sourced standalone-merge authorization helpers for enforce-epic-merge-gate.ps1.
4
+
5
+ .DESCRIPTION
6
+ Holds the fourth allow condition of the epic merge gate, the standalone-merge
7
+ authorization record, together with the two pure decision-envelope factories the gate
8
+ returns on every path.
9
+
10
+ This file is split out of the parent hook, .claude/hooks/enforce-epic-merge-gate.ps1,
11
+ because that file had 13 lines of headroom under the 500-line limit, which a fourth allow
12
+ condition, its predicates, and the rewritten header could not fit (issue #670). The two
13
+ envelope factories were moved here unchanged: they read no script-scoped state and no
14
+ test calls them by name, so the move is a pure relocation with no behaviour change.
15
+
16
+ Every function in this file is pure. It reads no file, starts no process, and reads no
17
+ clock; the parent hook supplies the already-parsed checkpoint objects and the live
18
+ envelope session_id. A test can therefore dot-source this file on its own.
19
+
20
+ Honest disclosure. The standalone-merge authorization record is a policy-level,
21
+ auditable declaration and is
22
+ not a cryptographic or security control.
23
+ It names a specific pull request, a specific session, an authorizer, a time, and a
24
+ stated basis, so that a standalone merge leaves a reviewable trail and cannot be reached
25
+ by accident or by a record written for a different pull request. authorized_by is a
26
+ declaration only: the hook checks that it is present and non-empty and does not verify
27
+ the identity it names, because the runtime exposes no attested agent identity at Bash
28
+ PreToolUse time. The record is not tamper-proof: any actor able to write
29
+ artifacts/orchestration/*.json inside the authorizing session can write a record,
30
+ because all agents share one filesystem and defaultPermissionMode is bypassPermissions.
31
+ The session_id cross-check binds a record to the session that emitted it, so a stale
32
+ record from a previous run or a record copied between runs authorizes nothing; it does
33
+ not stop same-session forgery. The mechanism converts an untraceable bypass into a
34
+ deliberate, attributable, auditable act. This is a documented accepted trade, not an
35
+ unexamined gap.
36
+
37
+ .NOTES
38
+ Compatible with PowerShell 7+. No external module dependencies.
39
+ #>
40
+ [CmdletBinding()]
41
+ param()
42
+
43
+ # Reason codes for the standalone-merge authorization branch. Each of the four assignments
44
+ # below is the only place its code literal appears in this file; every other use reads the
45
+ # variable, and the Codex hook's spelling is compared against these assignments by a test.
46
+ $script:StandaloneAbsentReasonCode = 'STANDALONE_MERGE_AUTHORIZATION_ABSENT'
47
+ $script:StandalonePrMismatchReasonCode = 'STANDALONE_MERGE_AUTHORIZATION_PR_MISMATCH'
48
+ $script:StandaloneMalformedReasonCode = 'STANDALONE_MERGE_AUTHORIZATION_MALFORMED'
49
+ $script:StandaloneNotPrSpecificReasonCode = 'STANDALONE_MERGE_AUTHORIZATION_NOT_PR_SPECIFIC'
50
+
51
+ # The checkpoint key that carries the authorization records, and the pointer every
52
+ # standalone deny message ends with.
53
+ $script:StandaloneAuthorizationKey = 'standalone_merge_authorizations'
54
+ $script:StandaloneAuthorizationGuidance = 'To authorize a standalone merge, write a standalone_merge_authorizations record naming this pull request and the current session_id into an orchestrator checkpoint, as described in the Standalone-Merge-Authorization section of .claude/rules/orchestrator-state.md.'
55
+
56
+ function Get-EpicMergeGateAllowDecision {
57
+ [CmdletBinding()]
58
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
59
+ param()
60
+
61
+ return [ordered]@{
62
+ hookSpecificOutput = [ordered]@{
63
+ hookEventName = 'PreToolUse'
64
+ permissionDecision = 'allow'
65
+ }
66
+ }
67
+ }
68
+
69
+ function Get-EpicMergeGateBlockDecision {
70
+ [CmdletBinding()]
71
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
72
+ param(
73
+ [Parameter(Mandatory)]
74
+ [string] $Reason
75
+ )
76
+
77
+ return [ordered]@{
78
+ hookSpecificOutput = [ordered]@{
79
+ hookEventName = 'PreToolUse'
80
+ permissionDecision = 'deny'
81
+ permissionDecisionReason = $Reason
82
+ }
83
+ }
84
+ }
85
+
86
+ function Test-StandalonePositiveJsonInteger {
87
+ <#
88
+ .SYNOPSIS
89
+ Report whether a parsed JSON value is an integer strictly greater than zero.
90
+ .DESCRIPTION
91
+ ConvertFrom-Json yields a 32- or 64-bit integer for a JSON integer, a double for a
92
+ JSON number with a fraction, and a string for a quoted value. Only the two integer
93
+ types qualify, so a string that spells digits is rejected on its type.
94
+ .OUTPUTS
95
+ System.Boolean
96
+ #>
97
+ [CmdletBinding()]
98
+ [OutputType([bool])]
99
+ param(
100
+ [AllowNull()]
101
+ $Value
102
+ )
103
+
104
+ if (-not ($Value -is [int] -or $Value -is [long])) {
105
+ return $false
106
+ }
107
+ return ($Value -gt 0)
108
+ }
109
+
110
+ function Test-StandaloneJsonObject {
111
+ <#
112
+ .SYNOPSIS
113
+ Report whether a parsed JSON value is an object rather than a scalar or an array.
114
+ .OUTPUTS
115
+ System.Boolean
116
+ #>
117
+ [CmdletBinding()]
118
+ [OutputType([bool])]
119
+ param(
120
+ [AllowNull()]
121
+ $Value
122
+ )
123
+
124
+ return ($Value -is [System.Management.Automation.PSCustomObject])
125
+ }
126
+
127
+ function Get-StandaloneRecordField {
128
+ <#
129
+ .SYNOPSIS
130
+ Read a named field off a parsed record, returning $null when the field is absent.
131
+ .OUTPUTS
132
+ System.Object, System.Object[], or $null
133
+ #>
134
+ [CmdletBinding()]
135
+ [OutputType([object], [object[]])]
136
+ param(
137
+ [Parameter(Mandatory)]
138
+ $Record,
139
+
140
+ [Parameter(Mandatory)]
141
+ [string] $Name
142
+ )
143
+
144
+ $property = $Record.PSObject.Properties[$Name]
145
+ if ($null -eq $property) {
146
+ return $null
147
+ }
148
+ # The unary comma keeps a one-element array from unrolling into its scalar, so a
149
+ # pr_number spelled as [691] is still seen as an array and rejected.
150
+ return , $property.Value
151
+ }
152
+
153
+ function Get-StandaloneMergeAuthorizationRecord {
154
+ <#
155
+ .SYNOPSIS
156
+ Select the authorization record for one pull request from a parsed checkpoint.
157
+ .DESCRIPTION
158
+ Returns the first entry of the checkpoint's authorization array that is an object
159
+ whose pr_number is a positive JSON integer equal to PrNumber, or nothing when no
160
+ such entry exists. Reads no file, starts no process, and reads no clock.
161
+ .PARAMETER Checkpoint
162
+ Parsed checkpoint object, or $null when the checkpoint is absent or unreadable.
163
+ .PARAMETER PrNumber
164
+ The pull request number extracted from the command under evaluation.
165
+ .OUTPUTS
166
+ System.Object or nothing
167
+ #>
168
+ [CmdletBinding()]
169
+ param(
170
+ [AllowNull()]
171
+ $Checkpoint,
172
+
173
+ [Parameter(Mandatory)]
174
+ [int] $PrNumber
175
+ )
176
+
177
+ if (-not (Test-StandaloneJsonObject -Value $Checkpoint)) {
178
+ return
179
+ }
180
+ $property = $Checkpoint.PSObject.Properties[$script:StandaloneAuthorizationKey]
181
+ if ($null -eq $property -or $property.Value -isnot [System.Array]) {
182
+ return
183
+ }
184
+ foreach ($entry in $property.Value) {
185
+ if (-not (Test-StandaloneJsonObject -Value $entry)) {
186
+ continue
187
+ }
188
+ $entryNumber = Get-StandaloneRecordField -Record $entry -Name 'pr_number'
189
+ if ((Test-StandalonePositiveJsonInteger -Value $entryNumber) -and $entryNumber -eq $PrNumber) {
190
+ return $entry
191
+ }
192
+ }
193
+ }
194
+
195
+ function Test-StandaloneNonBlankString {
196
+ <#
197
+ .SYNOPSIS
198
+ Report whether a value is a string whose trimmed length reaches a minimum.
199
+ .OUTPUTS
200
+ System.Boolean
201
+ #>
202
+ [CmdletBinding()]
203
+ [OutputType([bool])]
204
+ param(
205
+ [AllowNull()]
206
+ $Value,
207
+
208
+ [int] $MinimumLength = 1
209
+ )
210
+
211
+ if ($Value -isnot [string]) {
212
+ return $false
213
+ }
214
+ return ($Value.Trim().Length -ge $MinimumLength)
215
+ }
216
+
217
+ function Test-StandaloneAuthorizedAt {
218
+ <#
219
+ .SYNOPSIS
220
+ Report whether an authorized_at value is a parseable date-time.
221
+ .DESCRIPTION
222
+ ConvertFrom-Json already converts an ISO 8601 string into a date-time value, so
223
+ such a value is accepted as parsed. Any other string must parse with the invariant
224
+ culture, assuming and adjusting to UTC. No clock is read.
225
+ .OUTPUTS
226
+ System.Boolean
227
+ #>
228
+ [CmdletBinding()]
229
+ [OutputType([bool])]
230
+ param(
231
+ [AllowNull()]
232
+ $Value
233
+ )
234
+
235
+ if ($Value -is [DateTime] -or $Value -is [DateTimeOffset]) {
236
+ return $true
237
+ }
238
+ if (-not (Test-StandaloneNonBlankString -Value $Value)) {
239
+ return $false
240
+ }
241
+ $parsed = [DateTime]::MinValue
242
+ return [DateTime]::TryParse(
243
+ $Value,
244
+ [System.Globalization.CultureInfo]::InvariantCulture,
245
+ [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal,
246
+ [ref] $parsed)
247
+ }
248
+
249
+ function Get-StandaloneRecordFailure {
250
+ <#
251
+ .SYNOPSIS
252
+ Build the malformed-record result naming the failing field.
253
+ .OUTPUTS
254
+ System.Management.Automation.PSCustomObject
255
+ #>
256
+ [CmdletBinding()]
257
+ [OutputType([pscustomobject])]
258
+ param(
259
+ [Parameter(Mandatory)]
260
+ [string] $Field,
261
+
262
+ [Parameter(Mandatory)]
263
+ [string] $Rule
264
+ )
265
+
266
+ return [pscustomobject]@{
267
+ ReasonCode = $script:StandaloneMalformedReasonCode
268
+ Message = "the matched authorization record has an invalid '$Field': $Rule"
269
+ }
270
+ }
271
+
272
+ function Test-StandaloneMergeAuthorizationRecord {
273
+ <#
274
+ .SYNOPSIS
275
+ Validate the fields of a selected authorization record.
276
+ .DESCRIPTION
277
+ Runs the field checks in the fixed order pr_url, issue_num, branch_name,
278
+ authorized_by, authorized_at, basis, run_slug, session_id. Returns nothing when
279
+ every check passes; otherwise returns the first failure as an object carrying
280
+ ReasonCode and a Message that names the failing field. branch_name is checked for
281
+ presence only and is never compared against the live branch or working directory.
282
+ .PARAMETER Record
283
+ The parsed record selected by pr_number.
284
+ .PARAMETER EnvelopeSessionId
285
+ The session_id read from the live PreToolUse envelope. A blank value fails closed.
286
+ .OUTPUTS
287
+ System.Management.Automation.PSCustomObject or nothing
288
+ #>
289
+ [CmdletBinding()]
290
+ param(
291
+ [Parameter(Mandatory)]
292
+ $Record,
293
+
294
+ [AllowNull()]
295
+ [AllowEmptyString()]
296
+ [string] $EnvelopeSessionId
297
+ )
298
+
299
+ $prUrl = Get-StandaloneRecordField -Record $Record -Name 'pr_url'
300
+ $pullSuffix = '/pull/' + [string](Get-StandaloneRecordField -Record $Record -Name 'pr_number')
301
+ if (-not (Test-StandaloneNonBlankString -Value $prUrl) -or
302
+ -not $prUrl.EndsWith($pullSuffix, [System.StringComparison]::Ordinal)) {
303
+ return Get-StandaloneRecordFailure -Field 'pr_url' -Rule "it must end with $pullSuffix."
304
+ }
305
+ if (-not (Test-StandalonePositiveJsonInteger -Value (Get-StandaloneRecordField -Record $Record -Name 'issue_num'))) {
306
+ return Get-StandaloneRecordFailure -Field 'issue_num' -Rule 'it must be a JSON integer greater than zero.'
307
+ }
308
+ foreach ($name in @('branch_name', 'authorized_by')) {
309
+ if (-not (Test-StandaloneNonBlankString -Value (Get-StandaloneRecordField -Record $Record -Name $name))) {
310
+ return Get-StandaloneRecordFailure -Field $name -Rule 'it must be a non-empty string.'
311
+ }
312
+ }
313
+ if (-not (Test-StandaloneAuthorizedAt -Value (Get-StandaloneRecordField -Record $Record -Name 'authorized_at'))) {
314
+ return Get-StandaloneRecordFailure -Field 'authorized_at' -Rule 'it must be a parseable date-time string.'
315
+ }
316
+ if (-not (Test-StandaloneNonBlankString -Value (Get-StandaloneRecordField -Record $Record -Name 'basis') -MinimumLength 20)) {
317
+ return Get-StandaloneRecordFailure -Field 'basis' -Rule 'it must be at least 20 characters after trimming.'
318
+ }
319
+ if ($null -ne $Record.PSObject.Properties['run_slug'] -and
320
+ -not (Test-StandaloneNonBlankString -Value (Get-StandaloneRecordField -Record $Record -Name 'run_slug'))) {
321
+ return Get-StandaloneRecordFailure -Field 'run_slug' -Rule 'when present it must be a non-empty string.'
322
+ }
323
+ $recordSessionId = Get-StandaloneRecordField -Record $Record -Name 'session_id'
324
+ if (-not (Test-StandaloneNonBlankString -Value $recordSessionId) -or
325
+ [string]::IsNullOrWhiteSpace($EnvelopeSessionId) -or
326
+ -not [string]::Equals($recordSessionId, $EnvelopeSessionId, [System.StringComparison]::Ordinal)) {
327
+ return Get-StandaloneRecordFailure -Field 'session_id' -Rule 'it must be non-empty and equal the session_id of the live hook envelope.'
328
+ }
329
+ }
330
+
331
+ function Get-StandaloneAuthorizationBlockState {
332
+ <#
333
+ .SYNOPSIS
334
+ Classify one parsed checkpoint as Absent, NotPrSpecific, or PrSpecific.
335
+ .DESCRIPTION
336
+ Absent means the checkpoint is missing or carries no authorization key. The block
337
+ is PrSpecific only when it is a non-empty array whose every entry is an object with
338
+ a positive integer pr_number; any other shape, including a blanket boolean, is
339
+ NotPrSpecific, so a blanket flag is rejected visibly rather than read as absent.
340
+ .OUTPUTS
341
+ System.String
342
+ #>
343
+ [CmdletBinding()]
344
+ [OutputType([string])]
345
+ param(
346
+ [AllowNull()]
347
+ $Checkpoint
348
+ )
349
+
350
+ if (-not (Test-StandaloneJsonObject -Value $Checkpoint)) {
351
+ return 'Absent'
352
+ }
353
+ $property = $Checkpoint.PSObject.Properties[$script:StandaloneAuthorizationKey]
354
+ if ($null -eq $property) {
355
+ return 'Absent'
356
+ }
357
+ $block = $property.Value
358
+ if ($block -isnot [System.Array] -or $block.Count -eq 0) {
359
+ return 'NotPrSpecific'
360
+ }
361
+ foreach ($entry in $block) {
362
+ if (-not (Test-StandaloneJsonObject -Value $entry)) {
363
+ return 'NotPrSpecific'
364
+ }
365
+ if (-not (Test-StandalonePositiveJsonInteger -Value (Get-StandaloneRecordField -Record $entry -Name 'pr_number'))) {
366
+ return 'NotPrSpecific'
367
+ }
368
+ }
369
+ return 'PrSpecific'
370
+ }
371
+
372
+ function Test-StandaloneCheckpointAllowsMerge {
373
+ <#
374
+ .SYNOPSIS
375
+ Decision logic for the standalone-merge authorization path (branch 4).
376
+ .DESCRIPTION
377
+ Evaluates the three parsed orchestrator checkpoints for a record that authorizes
378
+ CommandPrNumber in the live session. Returns an object whose Allowed member is
379
+ $true on success; otherwise Allowed is $false and ReasonCode and Message describe
380
+ the denial:
381
+ - absent: no checkpoint carries the authorization key;
382
+ - not PR-specific: a checkpoint carries the key but names no specific pull request;
383
+ - PR mismatch: records exist, but none names CommandPrNumber;
384
+ - malformed: the matched record fails a field check, and the message names the field.
385
+ .PARAMETER Checkpoints
386
+ The three parsed checkpoint objects; any element may be $null.
387
+ .PARAMETER CommandPrNumber
388
+ The explicit pull request number extracted from the command.
389
+ .PARAMETER EnvelopeSessionId
390
+ The session_id read from the live PreToolUse envelope.
391
+ .OUTPUTS
392
+ System.Management.Automation.PSCustomObject
393
+ #>
394
+ [CmdletBinding()]
395
+ [OutputType([pscustomobject])]
396
+ param(
397
+ [AllowNull()]
398
+ [AllowEmptyCollection()]
399
+ [object[]] $Checkpoints,
400
+
401
+ [Parameter(Mandatory)]
402
+ [int] $CommandPrNumber,
403
+
404
+ [AllowNull()]
405
+ [AllowEmptyString()]
406
+ [string] $EnvelopeSessionId
407
+ )
408
+
409
+ $states = @(foreach ($checkpoint in @($Checkpoints)) { Get-StandaloneAuthorizationBlockState -Checkpoint $checkpoint })
410
+ $reasonCode = $null
411
+ $message = $null
412
+ if ($states -contains 'NotPrSpecific') {
413
+ $reasonCode = $script:StandaloneNotPrSpecificReasonCode
414
+ $message = 'an orchestrator checkpoint carries the authorization key but names no specific pull request; a blanket flag is not an authorization record.'
415
+ } elseif ($states -notcontains 'PrSpecific') {
416
+ $reasonCode = $script:StandaloneAbsentReasonCode
417
+ $message = 'no orchestrator checkpoint carries an authorization record, but an authorized standalone merge path exists.'
418
+ } else {
419
+ foreach ($checkpoint in @($Checkpoints)) {
420
+ $record = Get-StandaloneMergeAuthorizationRecord -Checkpoint $checkpoint -PrNumber $CommandPrNumber
421
+ if ($null -eq $record) {
422
+ continue
423
+ }
424
+ $failure = Test-StandaloneMergeAuthorizationRecord -Record $record -EnvelopeSessionId $EnvelopeSessionId
425
+ if ($null -eq $failure) {
426
+ return [pscustomobject]@{ Allowed = $true; ReasonCode = $null; Message = $null }
427
+ }
428
+ $reasonCode = $failure.ReasonCode
429
+ $message = $failure.Message
430
+ break
431
+ }
432
+ if ($null -eq $reasonCode) {
433
+ $reasonCode = $script:StandalonePrMismatchReasonCode
434
+ $message = "authorization records exist, but none names pull request $CommandPrNumber."
435
+ }
436
+ }
437
+
438
+ return [pscustomobject]@{
439
+ Allowed = $false
440
+ ReasonCode = $reasonCode
441
+ Message = "$message $script:StandaloneAuthorizationGuidance"
442
+ }
443
+ }