@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.
- package/out/mcp-server.js +3526 -1037
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +15 -2
- package/resources/claude-customizations/.claude/agents/parallel-planner.md +3 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +49 -14
- package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +52 -3
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +7 -1
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +103 -6
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +58 -3
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +37 -5
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +13 -2
- package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +35 -7
- package/resources/claude-customizations/.claude/hooks/hook-command-invocation.ps1 +483 -0
- package/resources/claude-customizations/.claude/hooks/hook-command-scanner.ps1 +483 -0
- package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +254 -5
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +18 -75
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +290 -0
- package/resources/claude-customizations/.claude/lib/cleanup-manifest/CleanupWorktreeManifest.psm1 +415 -0
- package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMerge.psm1 +355 -0
- package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMergeGrammar.psm1 +318 -0
- package/resources/claude-customizations/.claude/lib/project-file-merge/Resolve-MergeableConflict.ps1 +229 -0
- package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +66 -2
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +311 -16
- package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +42 -0
- package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +3 -1
- package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +36 -2
- package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +6 -0
- package/resources/claude-customizations/.claude/skills/powershell-orchestration-state-machine/SKILL.md +29 -1
- package/resources/claude-customizations/config/blast-radius.json +7 -0
- package/resources/claude-customizations/pack-manifests/core.json +7 -0
- package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +41 -0
- package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-state/SKILL.md +41 -0
- package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +26 -0
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +49 -3
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +57 -12
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-worktree-removal-gate.ps1 +34 -8
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +6 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +34 -7
- package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-invocation.ps1 +483 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-scanner.ps1 +483 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/validate-bash.ps1 +130 -2
- package/resources/codex-and-agents-customizations/pack-manifests/core.json +6 -1
- package/resources/config/orchestration-handoff-registry.json +138 -0
- package/resources/config/orchestration-handoff.schema.json +472 -0
- 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
|
package/resources/claude-customizations/.claude/lib/cleanup-manifest/CleanupWorktreeManifest.psm1
ADDED
|
@@ -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
|