@danmoisan/drm-copilot-mcp 1.1.9 → 1.1.11

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 (46) hide show
  1. package/out/mcp-server.js +3526 -1037
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +15 -2
  4. package/resources/claude-customizations/.claude/agents/parallel-planner.md +3 -0
  5. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +49 -14
  6. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +52 -3
  7. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +7 -1
  8. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +103 -6
  9. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +58 -3
  10. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +37 -5
  11. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +13 -2
  12. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +35 -7
  13. package/resources/claude-customizations/.claude/hooks/hook-command-invocation.ps1 +483 -0
  14. package/resources/claude-customizations/.claude/hooks/hook-command-scanner.ps1 +483 -0
  15. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +254 -5
  16. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +18 -75
  17. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +290 -0
  18. package/resources/claude-customizations/.claude/lib/cleanup-manifest/CleanupWorktreeManifest.psm1 +415 -0
  19. package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMerge.psm1 +355 -0
  20. package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMergeGrammar.psm1 +318 -0
  21. package/resources/claude-customizations/.claude/lib/project-file-merge/Resolve-MergeableConflict.ps1 +229 -0
  22. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +66 -2
  23. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +311 -16
  24. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +42 -0
  25. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +3 -1
  26. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +36 -2
  27. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +6 -0
  28. package/resources/claude-customizations/.claude/skills/powershell-orchestration-state-machine/SKILL.md +29 -1
  29. package/resources/claude-customizations/config/blast-radius.json +7 -0
  30. package/resources/claude-customizations/pack-manifests/core.json +7 -0
  31. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +41 -0
  32. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-state/SKILL.md +41 -0
  33. package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +26 -0
  34. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  35. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +49 -3
  36. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +57 -12
  37. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-worktree-removal-gate.ps1 +34 -8
  38. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +6 -1
  39. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +34 -7
  40. package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-invocation.ps1 +483 -0
  41. package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-scanner.ps1 +483 -0
  42. package/resources/codex-and-agents-customizations/.codex/hooks/validate-bash.ps1 +130 -2
  43. package/resources/codex-and-agents-customizations/pack-manifests/core.json +6 -1
  44. package/resources/config/orchestration-handoff-registry.json +138 -0
  45. package/resources/config/orchestration-handoff.schema.json +472 -0
  46. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +38 -0
@@ -0,0 +1,290 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Mechanically-mergeable path exclusion and the shared overlap helpers.
4
+
5
+ .DESCRIPTION
6
+ Destination-runtime PowerShell mirror of
7
+ scripts/dev_tools/_blast_radius_mergeable.py (config_mergeable_paths,
8
+ matches_mergeable_path, exclude_mergeable_paths), plus the two overlap helpers
9
+ the relation calls (ports of _smallest_path_overlap and _smallest_common,
10
+ relocated here from BlastRadius.psm1).
11
+
12
+ The mergeable path class names the project-file shapes whose overlap a merge
13
+ step can reconcile without re-delegating the work (issue #643). Two items
14
+ touching the same .csproj are not genuinely in contention, so the class is
15
+ removed from both radii immediately before the relation compares them.
16
+
17
+ Parity notes for maintainers:
18
+ - The exclusion is applied ONLY inside Test-BlastRadiusConflict and changes
19
+ no radius record: a radius still lists every project file it cited, so
20
+ drift detection and validation see the paths they saw before this key.
21
+ - The key is optional and fail-closed: an absent mergeable_paths key and an
22
+ empty list both exclude nothing and reproduce pre-change behaviour.
23
+ - Every comparison is ordinal, matching the Python mirror's str equality.
24
+ CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
25
+ #>
26
+
27
+ Set-StrictMode -Version Latest
28
+ $ErrorActionPreference = 'Stop'
29
+
30
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -ErrorAction Stop
31
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force -ErrorAction Stop
32
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force -ErrorAction Stop
33
+
34
+ # Truth-table key naming the class, declared as a constant so the reader, its
35
+ # tests, and the Python mirror all name one string rather than a literal.
36
+ $script:ConfigMergeablePathKey = 'mergeable_paths'
37
+
38
+ # Prefix making a configured pattern match at any depth. It is stripped in the
39
+ # third matching step so a root-level file can satisfy it (Test-MergeablePath).
40
+ $script:AnyDepthPrefix = '**/'
41
+
42
+ # Separator used in an overlapping-pair detail string. The pair is ordered
43
+ # ordinally before formatting so the detail is identical in both argument orders.
44
+ $script:PairDetailSeparator = ' ~ '
45
+
46
+
47
+ function Get-ConfigMergeablePath {
48
+ <#
49
+ .SYNOPSIS
50
+ Read the mechanically-mergeable path list from the truth table.
51
+
52
+ .DESCRIPTION
53
+ Port of config_mergeable_paths. The entries name project-file shapes,
54
+ normally anchored globs such as **/*.csproj (issue #643).
55
+
56
+ .PARAMETER Config
57
+ Parsed config/blast-radius.json. Only the mergeable_paths key is read.
58
+
59
+ .OUTPUTS
60
+ System.Object[]. Entries sorted and deduplicated by the underlying reader.
61
+ A config with no mergeable_paths key yields an empty array.
62
+ #>
63
+ [CmdletBinding()]
64
+ [OutputType([System.Object[]])]
65
+ param(
66
+ [Parameter(Mandatory = $true)]
67
+ [AllowNull()]
68
+ [object] $Config
69
+ )
70
+
71
+ return @(Get-ConfigStringList -Config $Config -Key $script:ConfigMergeablePathKey)
72
+ }
73
+
74
+ function Test-MergeablePath {
75
+ <#
76
+ .SYNOPSIS
77
+ Report whether one radius entry belongs to the mergeable path class.
78
+
79
+ .DESCRIPTION
80
+ Port of matches_mergeable_path. Three comparisons apply in order. The
81
+ first two are the read-by-mandate rules, delegated to Test-MandateRead
82
+ rather than restated: ordinal equality, the only rule that can settle a
83
+ glob entry, then glob containment for a concrete entry only. The third is
84
+ specific to this class, because an anchored pattern requires a separator
85
+ and so excludes a root-level file; retesting a concrete entry with the
86
+ anchor removed admits packages.config at the repository root.
87
+
88
+ .PARAMETER Entry
89
+ One radius paths entry: a concrete repository-relative path or a glob.
90
+
91
+ .PARAMETER MergeablePath
92
+ Configured patterns from Get-ConfigMergeablePath. An empty collection
93
+ matches nothing.
94
+
95
+ .OUTPUTS
96
+ System.Boolean. True when the entry belongs to the mergeable class and is
97
+ therefore excluded from the comparison. A glob entry is never mergeable
98
+ unless it equals a configured pattern character for character.
99
+ #>
100
+ [CmdletBinding()]
101
+ [OutputType([bool])]
102
+ param(
103
+ [Parameter(Mandatory = $true)]
104
+ [AllowEmptyString()]
105
+ [string] $Entry,
106
+ [Parameter(Mandatory = $true)]
107
+ [AllowEmptyCollection()]
108
+ [AllowEmptyString()]
109
+ [string[]] $MergeablePath
110
+ )
111
+
112
+ # Steps one and two are the mandate-read rules verbatim, reused rather than
113
+ # duplicated: a divergence would silently split two exclusions that share a
114
+ # vocabulary.
115
+ if (Test-MandateRead -Entry $Entry -MandateRead $MergeablePath) {
116
+ return $true
117
+ }
118
+
119
+ # A glob entry that did not match exactly is left alone. Only a concrete path
120
+ # reaches the anchor-stripping step.
121
+ if (Test-GlobEntry -Entry $Entry) {
122
+ return $false
123
+ }
124
+
125
+ # Retest against each anchored pattern with its anchor removed, which is the
126
+ # only way a root-level file can satisfy an anchored pattern.
127
+ foreach ($pattern in $MergeablePath) {
128
+ if (-not $pattern.StartsWith($script:AnyDepthPrefix, [System.StringComparison]::Ordinal)) {
129
+ continue
130
+ }
131
+
132
+ $stripped = $pattern.Substring($script:AnyDepthPrefix.Length)
133
+ # A stripped pattern that still carries a wildcard is a glob and is
134
+ # matched as one; a wildcard-free remainder can only be compared for
135
+ # ordinal equality.
136
+ if (Test-GlobEntry -Entry $stripped) {
137
+ if (Test-GlobMatch -Pattern $stripped -Candidate $Entry) {
138
+ return $true
139
+ }
140
+ } elseif ([string]::Equals($stripped, $Entry, [System.StringComparison]::Ordinal)) {
141
+ return $true
142
+ }
143
+ }
144
+
145
+ return $false
146
+ }
147
+
148
+ function Get-NonMergeablePathEntry {
149
+ <#
150
+ .SYNOPSIS
151
+ Drop every mechanically-mergeable entry from a collection of paths.
152
+
153
+ .DESCRIPTION
154
+ Port of exclude_mergeable_paths. The caller passes the content by value,
155
+ so the radius record is never rewritten; only the contention comparison
156
+ sees the filtered collection.
157
+
158
+ .PARAMETER Entry
159
+ Radius paths entries to filter. An empty collection is accepted.
160
+
161
+ .PARAMETER MergeablePath
162
+ Configured patterns from Get-ConfigMergeablePath. An empty collection
163
+ excludes nothing, so the returned content equals the input.
164
+
165
+ .OUTPUTS
166
+ System.Object[]. Surviving entries, deduplicated and ordinally sorted.
167
+ #>
168
+ [CmdletBinding()]
169
+ [OutputType([System.Object[]])]
170
+ param(
171
+ [Parameter(Mandatory = $true)]
172
+ [AllowEmptyCollection()]
173
+ [AllowEmptyString()]
174
+ [string[]] $Entry,
175
+ [Parameter(Mandatory = $true)]
176
+ [AllowEmptyCollection()]
177
+ [AllowEmptyString()]
178
+ [string[]] $MergeablePath
179
+ )
180
+
181
+ $survivor = [System.Collections.Generic.List[string]]::new()
182
+ foreach ($candidate in $Entry) {
183
+ # Deduplication and ordering are the sorter's job, matching the Python
184
+ # port's set-then-sorted construction.
185
+ if (-not (Test-MergeablePath -Entry $candidate -MergeablePath $MergeablePath)) {
186
+ $survivor.Add($candidate)
187
+ }
188
+ }
189
+
190
+ return @(Get-OrdinalSortedEntry -Entry $survivor.ToArray())
191
+ }
192
+
193
+ function Get-SmallestPathOverlap {
194
+ <#
195
+ .SYNOPSIS
196
+ Return the ordinally smallest overlapping path pair, or $null.
197
+
198
+ .DESCRIPTION
199
+ Port of _smallest_path_overlap. Each overlapping pair is ordered before
200
+ it is recorded, so the minimum is taken over a set that does not depend
201
+ on argument order; that is what makes the reported detail symmetric.
202
+
203
+ .PARAMETER PathA
204
+ First radius path collection. An empty collection is accepted.
205
+
206
+ .PARAMETER PathB
207
+ Second radius path collection. An empty collection is accepted.
208
+
209
+ .OUTPUTS
210
+ System.String. The smallest joined pair, or $null when nothing overlaps.
211
+ #>
212
+ [CmdletBinding()]
213
+ [OutputType([string])]
214
+ param(
215
+ [Parameter(Mandatory = $true)]
216
+ [AllowEmptyCollection()]
217
+ [AllowEmptyString()]
218
+ [string[]] $PathA,
219
+ [Parameter(Mandatory = $true)]
220
+ [AllowEmptyCollection()]
221
+ [AllowEmptyString()]
222
+ [string[]] $PathB
223
+ )
224
+
225
+ $detail = [System.Collections.Generic.List[string]]::new()
226
+ foreach ($entryA in $PathA) {
227
+ foreach ($entryB in $PathB) {
228
+ if (-not (Test-EntryOverlap -EntryA $entryA -EntryB $entryB)) {
229
+ continue
230
+ }
231
+ $ordered = if ([string]::CompareOrdinal($entryA, $entryB) -le 0) {
232
+ @($entryA, $entryB)
233
+ } else {
234
+ @($entryB, $entryA)
235
+ }
236
+ $detail.Add($ordered -join $script:PairDetailSeparator)
237
+ }
238
+ }
239
+
240
+ return (Get-OrdinalSmallestEntry -Entry $detail.ToArray())
241
+ }
242
+
243
+ function Get-SmallestCommonEntry {
244
+ <#
245
+ .SYNOPSIS
246
+ Return the ordinally smallest entry present in both collections.
247
+
248
+ .DESCRIPTION
249
+ Port of _smallest_common. Two empty collections share nothing, so the
250
+ result is $null and the level contributes no reason.
251
+
252
+ .PARAMETER Left
253
+ First collection. An empty collection is accepted.
254
+
255
+ .PARAMETER Right
256
+ Second collection. An empty collection is accepted.
257
+
258
+ .OUTPUTS
259
+ System.String. The smallest common entry, or $null when there is none.
260
+ #>
261
+ [CmdletBinding()]
262
+ [OutputType([string])]
263
+ param(
264
+ [Parameter(Mandatory = $true)]
265
+ [AllowEmptyCollection()]
266
+ [AllowEmptyString()]
267
+ [string[]] $Left,
268
+ [Parameter(Mandatory = $true)]
269
+ [AllowEmptyCollection()]
270
+ [AllowEmptyString()]
271
+ [string[]] $Right
272
+ )
273
+
274
+ $rightSet = [System.Collections.Generic.HashSet[string]]::new($Right, [StringComparer]::Ordinal)
275
+ $common = [System.Collections.Generic.List[string]]::new()
276
+ foreach ($entry in $Left) {
277
+ if ($rightSet.Contains($entry)) {
278
+ $common.Add($entry)
279
+ }
280
+ }
281
+
282
+ return (Get-OrdinalSmallestEntry -Entry $common.ToArray())
283
+ }
284
+
285
+ Export-ModuleMember -Function `
286
+ Get-ConfigMergeablePath, `
287
+ Test-MergeablePath, `
288
+ Get-NonMergeablePathEntry, `
289
+ Get-SmallestPathOverlap, `
290
+ Get-SmallestCommonEntry
@@ -0,0 +1,415 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Sanctioned-removal manifest reader for the two worktree-removal gate hooks
4
+ (issue #635).
5
+
6
+ .DESCRIPTION
7
+ The cleanup-merged-worktrees skill records each per-worktree removal it has
8
+ triaged, together with the verdict and the evidence behind that verdict, in a
9
+ manifest document. This module owns reading that document and deciding whether
10
+ a given removal target is authorized by it. Both PreToolUse gate hooks consume
11
+ the decision; neither re-implements the parse.
12
+
13
+ Vocabulary. Two script-scope constants carry the narrow allow-sets the
14
+ predicate is written against, following the $script:AllowedMergeStatuses
15
+ precedent the two gate hooks already use. The removal-disposition set holds
16
+ exactly SAFE_TO_DELETE. The authorized-branch-state set holds exactly
17
+ NOT_MERGED and HAS_UNIQUE_RESIDUALS, which are the two states the skill
18
+ permanently forbids adding to the cleanup script's apply-mode allowlist and are
19
+ therefore the durable residual this manifest exists to serve.
20
+
21
+ Fail-closed posture. Every malformation resolves to a null parsed object or a
22
+ false predicate result. The module raises nothing on a malformed manifest, so a
23
+ hook consuming it always reaches its own unchanged deny path rather than
24
+ throwing.
25
+
26
+ .NOTES
27
+ Compatible with PowerShell 7+. No external module dependencies, no subprocess,
28
+ and no network. The single filesystem read and the single wall-clock read each
29
+ sit behind an injectable seam so tests drive them without writing a temporary
30
+ file. Mirrored byte-identically under
31
+ extensions/drm-copilot/resources/claude-customizations/.
32
+ CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
33
+ #>
34
+
35
+ Set-StrictMode -Version Latest
36
+ $ErrorActionPreference = 'Stop'
37
+
38
+ # Repo-relative location of the sanctioned-removal manifest. Kept as a script-scope
39
+ # constant so the read seam is the only function that names the path.
40
+ $script:CleanupWorktreeManifestPath = 'artifacts/orchestration/cleanup-worktrees-manifest.json'
41
+
42
+ # The only removal disposition that authorizes anything. Deliberately a
43
+ # single-member set rather than a bare string comparison, so a widening of the set
44
+ # is a visible edit to a named constant that a test pins.
45
+ $script:AllowedRemovalDispositions = @('SAFE_TO_DELETE')
46
+
47
+ # The branch states a manifest record may authorize a removal for. PROTECTED_CURRENT
48
+ # is never authorized, and the three merged states are excluded because the cleanup
49
+ # script's deterministic path owns them.
50
+ $script:AuthorizedBranchStates = @('NOT_MERGED', 'HAS_UNIQUE_RESIDUALS')
51
+
52
+ # The step-5 verdicts that may authorize a removal. Derived from the skill's
53
+ # five-member verdict vocabulary -- DEAD_ONE_OFF, ALREADY_SOLVED_ELSEWHERE,
54
+ # STALE_OR_CONTRADICTED, GENUINELY_NEW, STILL_RELEVANT -- with the two
55
+ # preserve-implying members removed, because the skill classifies both as content
56
+ # that must be preserved before the worktree is deleted. Recorded as the authorized
57
+ # subset rather than as the vocabulary plus an exclusion list, so a verdict added to
58
+ # the vocabulary later is not authorized by silence.
59
+ $script:AuthorizedRemovalVerdicts = @('DEAD_ONE_OFF', 'ALREADY_SOLVED_ELSEWHERE', 'STALE_OR_CONTRADICTED')
60
+
61
+ # The self-identifying discriminator and the contract version this module reads.
62
+ $script:ExpectedManifestTool = 'cleanup-merged-worktrees'
63
+ $script:ExpectedManifestSchemaVersion = 1
64
+
65
+ # Freshness bound. artifacts/ is gitignored, so a stale manifest persists after its
66
+ # run ends, and cleanup targets are ordinary long-lived worktree paths rather than
67
+ # session-stamped ones, which makes a stale collision materially re-matchable.
68
+ $script:ManifestFreshnessBound = [timespan]::FromHours(24)
69
+
70
+ function Get-CleanupWorktreeManifestContent {
71
+ <#
72
+ .SYNOPSIS
73
+ Read the raw JSON text of the sanctioned-removal manifest. Tests mock this
74
+ function (read seam).
75
+ .DESCRIPTION
76
+ The only filesystem read in this module. An absent manifest is an ordinary
77
+ state rather than an error, so the function returns $null instead of
78
+ throwing and the caller resolves it to a non-authorizing parse.
79
+ .OUTPUTS
80
+ System.String or $null
81
+ #>
82
+ [CmdletBinding()]
83
+ [OutputType([string])]
84
+ param()
85
+
86
+ if (-not (Test-Path -LiteralPath $script:CleanupWorktreeManifestPath -PathType Leaf)) {
87
+ return $null
88
+ }
89
+ return (Get-Content -LiteralPath $script:CleanupWorktreeManifestPath -Raw)
90
+ }
91
+
92
+ function Get-CleanupWorktreeManifestUtcNow {
93
+ <#
94
+ .SYNOPSIS
95
+ Read the current UTC time. Tests mock this function (clock seam).
96
+ .DESCRIPTION
97
+ The only wall-clock read in this module. Freshness evaluation calls it once
98
+ per predicate evaluation, so a mocked value governs the whole comparison and
99
+ no test depends on real elapsed time.
100
+ .OUTPUTS
101
+ System.DateTime
102
+ #>
103
+ [CmdletBinding()]
104
+ [OutputType([datetime])]
105
+ param()
106
+
107
+ return [datetime]::UtcNow
108
+ }
109
+
110
+ function ConvertTo-CleanupWorktreeManifestNormalizedPath {
111
+ <#
112
+ .SYNOPSIS
113
+ Normalize a worktree path so both sides of a comparison share one spelling.
114
+ .DESCRIPTION
115
+ Replaces backslashes with forward slashes, then trims a single trailing
116
+ slash. Applied to the command target and to every recorded worktree_path,
117
+ so a trailing-slash target, a Windows-separator target, and a recorded
118
+ POSIX path all compare equal.
119
+ .PARAMETER Path
120
+ The raw path to normalize, or $null.
121
+ .OUTPUTS
122
+ System.String
123
+ #>
124
+ [CmdletBinding()]
125
+ [OutputType([string])]
126
+ param(
127
+ [AllowNull()]
128
+ [AllowEmptyString()]
129
+ [string] $Path
130
+ )
131
+
132
+ if ([string]::IsNullOrWhiteSpace($Path)) {
133
+ return ''
134
+ }
135
+ return ($Path -replace '\\', '/').TrimEnd('/')
136
+ }
137
+
138
+ function Find-CleanupWorktreeManifestRemovalRecord {
139
+ <#
140
+ .SYNOPSIS
141
+ Locate the removals[] record whose worktree_path matches the removal target.
142
+ .DESCRIPTION
143
+ Parses the manifest text behind a fail-closed try/catch: unparseable text
144
+ yields $null rather than a throw, matching the gate hooks' conventions.
145
+ Scanning stops at the FIRST normalized path match, so two records sharing a
146
+ path resolve on the first, following the parallel-branch precedent in the
147
+ epic gate. A record with no worktree_path key is skipped and the scan
148
+ continues.
149
+ .PARAMETER Raw
150
+ Raw manifest text, or $null when the manifest does not exist.
151
+ .PARAMETER WorktreePath
152
+ The removal target extracted from the command text.
153
+ .OUTPUTS
154
+ System.Object or $null
155
+ #>
156
+ [CmdletBinding()]
157
+ param(
158
+ [AllowNull()]
159
+ [AllowEmptyString()]
160
+ [string] $Raw,
161
+
162
+ [AllowNull()]
163
+ [AllowEmptyString()]
164
+ [string] $WorktreePath
165
+ )
166
+
167
+ if ([string]::IsNullOrWhiteSpace($Raw) -or [string]::IsNullOrWhiteSpace($WorktreePath)) {
168
+ return $null
169
+ }
170
+
171
+ try {
172
+ $manifest = $Raw | ConvertFrom-Json
173
+ } catch {
174
+ $manifest = $null
175
+ }
176
+ if ($null -eq $manifest) {
177
+ return $null
178
+ }
179
+
180
+ $manifestProperties = @($manifest.PSObject.Properties.Name)
181
+ if ($manifestProperties -notcontains 'removals') {
182
+ return $null
183
+ }
184
+
185
+ $normalizedTarget = ConvertTo-CleanupWorktreeManifestNormalizedPath -Path $WorktreePath
186
+
187
+ foreach ($record in @($manifest.removals)) {
188
+ if ($null -eq $record) {
189
+ continue
190
+ }
191
+ $recordProperties = @($record.PSObject.Properties.Name)
192
+ if ($recordProperties -notcontains 'worktree_path') {
193
+ continue
194
+ }
195
+ $normalizedRecordPath = ConvertTo-CleanupWorktreeManifestNormalizedPath -Path ([string]$record.worktree_path)
196
+ if ($normalizedRecordPath -eq $normalizedTarget) {
197
+ return $record
198
+ }
199
+ }
200
+ return $null
201
+ }
202
+
203
+ function Test-CleanupWorktreeManifestAuthorizesRemoval {
204
+ <#
205
+ .SYNOPSIS
206
+ Decide whether the sanctioned-removal manifest authorizes removing a
207
+ worktree.
208
+ .DESCRIPTION
209
+ Evaluates conditions 1 through 9 of the specification's allow predicate in
210
+ order: parse, tool and schema version, freshness against the injected
211
+ clock, removals present and non-empty, first normalized path match, removal
212
+ disposition, evidence, verdict, and branch state. Conditions 1 through 4
213
+ are evaluated before the path scan so an absent or malformed manifest costs
214
+ one existence test and returns to the caller's deny path.
215
+
216
+ The function returns a boolean and raises nothing: every malformation
217
+ resolves to $false. It never reads the preserved_files array, which belongs
218
+ to a different consumer and must not influence any gate decision.
219
+
220
+ Condition 10, checkpoint exclusion, is NOT evaluated here. It is the
221
+ caller's obligation, because the two gate hooks own the checkpoint seams.
222
+ .PARAMETER WorktreePath
223
+ The removal target extracted from the command text.
224
+ .OUTPUTS
225
+ System.Boolean
226
+ #>
227
+ [CmdletBinding()]
228
+ [OutputType([bool])]
229
+ param(
230
+ [AllowNull()]
231
+ [AllowEmptyString()]
232
+ [string] $WorktreePath
233
+ )
234
+
235
+ if ([string]::IsNullOrWhiteSpace($WorktreePath)) {
236
+ return $false
237
+ }
238
+
239
+ # Condition 1 -- the manifest exists, is readable, and parses as JSON.
240
+ $raw = Get-CleanupWorktreeManifestContent
241
+ if ([string]::IsNullOrWhiteSpace($raw)) {
242
+ return $false
243
+ }
244
+ try {
245
+ $manifest = $raw | ConvertFrom-Json
246
+ } catch {
247
+ $manifest = $null
248
+ }
249
+ if ($null -eq $manifest) {
250
+ return $false
251
+ }
252
+ $manifestProperties = @($manifest.PSObject.Properties.Name)
253
+
254
+ # Condition 2 -- the self-identifying discriminator and the contract version.
255
+ if ($manifestProperties -notcontains 'tool' -or $manifestProperties -notcontains 'schema_version') {
256
+ return $false
257
+ }
258
+ if ($manifest.tool -isnot [string] -or $manifest.tool -cne $script:ExpectedManifestTool) {
259
+ return $false
260
+ }
261
+ $schemaVersion = $manifest.schema_version
262
+ if ($schemaVersion -isnot [int] -and $schemaVersion -isnot [long]) {
263
+ return $false
264
+ }
265
+ if ([long]$schemaVersion -ne [long]$script:ExpectedManifestSchemaVersion) {
266
+ return $false
267
+ }
268
+
269
+ # Condition 3 -- freshness, measured against the injected clock only.
270
+ if ($manifestProperties -notcontains 'generated_at') {
271
+ return $false
272
+ }
273
+ $generatedAt = [datetime]::MinValue
274
+ $parseStyles = [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal
275
+ $isTimestampParsed = [datetime]::TryParse(
276
+ [string]$manifest.generated_at,
277
+ [cultureinfo]::InvariantCulture,
278
+ $parseStyles,
279
+ [ref] $generatedAt)
280
+ if (-not $isTimestampParsed) {
281
+ return $false
282
+ }
283
+ $utcNow = Get-CleanupWorktreeManifestUtcNow
284
+ if ($generatedAt -gt $utcNow) {
285
+ return $false
286
+ }
287
+ if (($utcNow - $generatedAt) -gt $script:ManifestFreshnessBound) {
288
+ return $false
289
+ }
290
+
291
+ # Condition 4 -- removals is present, is an array, and is non-empty.
292
+ if ($manifestProperties -notcontains 'removals') {
293
+ return $false
294
+ }
295
+ $removals = $manifest.removals
296
+ if ($null -eq $removals -or $removals -isnot [System.Collections.IList]) {
297
+ return $false
298
+ }
299
+ if (@($removals).Count -lt 1) {
300
+ return $false
301
+ }
302
+
303
+ # Condition 5 -- the first record whose normalized path matches the target.
304
+ $record = Find-CleanupWorktreeManifestRemovalRecord -Raw $raw -WorktreePath $WorktreePath
305
+ if ($null -eq $record) {
306
+ return $false
307
+ }
308
+ $recordProperties = @($record.PSObject.Properties.Name)
309
+
310
+ # Condition 6 -- the removal disposition is in the single-member allowed set.
311
+ if ($recordProperties -notcontains 'removal_disposition') {
312
+ return $false
313
+ }
314
+ if ($script:AllowedRemovalDispositions -cnotcontains ([string]$record.removal_disposition)) {
315
+ return $false
316
+ }
317
+
318
+ # Condition 7 -- the evidence justification is a present, non-empty string.
319
+ if ($recordProperties -notcontains 'evidence') {
320
+ return $false
321
+ }
322
+ if ($record.evidence -isnot [string] -or [string]::IsNullOrWhiteSpace($record.evidence)) {
323
+ return $false
324
+ }
325
+
326
+ # Condition 8 -- the verdict is one the skill does not classify as preserve.
327
+ if ($recordProperties -notcontains 'verdict') {
328
+ return $false
329
+ }
330
+ if ($script:AuthorizedRemovalVerdicts -cnotcontains ([string]$record.verdict)) {
331
+ return $false
332
+ }
333
+
334
+ # Condition 9 -- the branch state is one of the two durable-residual states.
335
+ if ($recordProperties -notcontains 'branch_state') {
336
+ return $false
337
+ }
338
+ if ($script:AuthorizedBranchStates -cnotcontains ([string]$record.branch_state)) {
339
+ return $false
340
+ }
341
+
342
+ return $true
343
+ }
344
+
345
+ function Test-CleanupManifestCheckpointCoversPath {
346
+ <#
347
+ .SYNOPSIS
348
+ Report whether an orchestration checkpoint records the removal target at
349
+ all.
350
+ .DESCRIPTION
351
+ Condition 10 of the specification's allow predicate. This is a PRESENCE
352
+ test, deliberately not an authorization test: a record matching the target
353
+ makes the manifest branch inapplicable regardless of that record's
354
+ merge_status, so a removal the checkpoint does not authorize still reaches
355
+ the gate's existing deny. Reusing the gates' merge_status predicate here
356
+ would reopen the one path by which the manifest could widen what the gates
357
+ protect.
358
+ .PARAMETER Checkpoint
359
+ Parsed checkpoint object, or $null when absent or unreadable.
360
+ .PARAMETER RecordArrayName
361
+ Name of the record array to scan: features for the epic checkpoint, items
362
+ for the parallel checkpoint.
363
+ .PARAMETER WorktreePath
364
+ The removal target extracted from the command text.
365
+ .OUTPUTS
366
+ System.Boolean
367
+ #>
368
+ [CmdletBinding()]
369
+ [OutputType([bool])]
370
+ param(
371
+ [AllowNull()]
372
+ $Checkpoint,
373
+
374
+ [Parameter(Mandatory = $true)]
375
+ [ValidateNotNullOrEmpty()]
376
+ [string] $RecordArrayName,
377
+
378
+ [AllowNull()]
379
+ [AllowEmptyString()]
380
+ [string] $WorktreePath
381
+ )
382
+
383
+ if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($WorktreePath)) {
384
+ return $false
385
+ }
386
+ $checkpointProperties = @($Checkpoint.PSObject.Properties.Name)
387
+ if ($checkpointProperties -notcontains $RecordArrayName) {
388
+ return $false
389
+ }
390
+
391
+ $normalizedTarget = ConvertTo-CleanupWorktreeManifestNormalizedPath -Path $WorktreePath
392
+
393
+ foreach ($record in @($Checkpoint.PSObject.Properties[$RecordArrayName].Value)) {
394
+ if ($null -eq $record) {
395
+ continue
396
+ }
397
+ $recordProperties = @($record.PSObject.Properties.Name)
398
+ if ($recordProperties -notcontains 'worktree_path') {
399
+ continue
400
+ }
401
+ $normalizedRecordPath = ConvertTo-CleanupWorktreeManifestNormalizedPath -Path ([string]$record.worktree_path)
402
+ if ($normalizedRecordPath -eq $normalizedTarget) {
403
+ return $true
404
+ }
405
+ }
406
+ return $false
407
+ }
408
+
409
+ Export-ModuleMember -Function `
410
+ Get-CleanupWorktreeManifestContent, `
411
+ Get-CleanupWorktreeManifestUtcNow, `
412
+ ConvertTo-CleanupWorktreeManifestNormalizedPath, `
413
+ Find-CleanupWorktreeManifestRemovalRecord, `
414
+ Test-CleanupWorktreeManifestAuthorizesRemoval, `
415
+ Test-CleanupManifestCheckpointCoversPath