@danmoisan/drm-copilot-mcp 1.0.21 → 1.0.22
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 +1624 -190
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/MEMORY.md +5 -1
- package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/feedback_commit_push_memory_before_pr.md +48 -2
- package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/feedback_no_sendmessage_tool.md +35 -0
- package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/feedback_worktree_isolation_branches_from_main.md +45 -0
- package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +238 -0
- package/resources/claude-customizations/.claude/agents/parallel-planner.md +149 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-invocation-origin.ps1 +23 -11
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +259 -0
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +499 -0
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate-helpers.ps1 +302 -0
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +359 -0
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +244 -0
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +379 -0
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +491 -0
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +490 -0
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +429 -0
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +366 -0
- package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +184 -0
- package/resources/claude-customizations/.claude/settings.json +25 -0
- package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +148 -0
- package/resources/claude-customizations/.claude/skills/parallel-close/SKILL.md +93 -0
- package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +960 -0
- package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +420 -0
- package/resources/claude-customizations/.claude/skills/parallel-remove/SKILL.md +176 -0
- package/resources/claude-customizations/.claude/skills/parallel-run/SKILL.md +56 -0
- package/resources/claude-customizations/pack-manifests/core.json +19 -1
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
- package/resources/config/orchestration-routing.json +22 -0
- package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +29 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Dot-sourced shape-and-derivation helpers for enforce-parallel-drift-gate.ps1.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
Provides the eight pure helpers the Layer 1 parallel drift gate uses to read checkpoint
|
|
7
|
+
shape and derive which item keys have an unresolved latest drift event. They are split out
|
|
8
|
+
of the parent hook, .claude/hooks/enforce-parallel-drift-gate.ps1, because that file had
|
|
9
|
+
reached the 500-line limit with no headroom; the split is a pure move with no behaviour
|
|
10
|
+
change (issue #446 remediation cycle 1, finding F8-N10).
|
|
11
|
+
|
|
12
|
+
- Test-ParallelDriftGateItemKey: positive non-boolean integer issue_num test.
|
|
13
|
+
- Test-ParallelDriftGateText: non-empty string test.
|
|
14
|
+
- Test-ParallelDriftGateCanonicalTimestamp: canonical yyyy-MM-ddTHH-mm shape test.
|
|
15
|
+
- Test-ParallelDriftGateEventRecord: per-entry drift_events[] shape test.
|
|
16
|
+
- Get-ParallelDriftGateLatestEventMap: reduces drift_events[] to each item's latest at.
|
|
17
|
+
- Get-ParallelDriftGateItemRadiusMap: indexes readable items[].blast_radius by item key.
|
|
18
|
+
- Test-ParallelDriftGateEventResolved: applies the re-recorded-radius resolution disjunct.
|
|
19
|
+
- Get-ParallelDriftGateUnresolvedState: derives the unresolved item keys.
|
|
20
|
+
|
|
21
|
+
Resolution semantics are owned by Python, not reimplemented here. That module's
|
|
22
|
+
unresolved_drift_item_keys derives resolution from two disjuncts: (a) the recorded radius
|
|
23
|
+
widened to cover every escaped path, evaluated with F1's is_path_subsumed glob semantics; or
|
|
24
|
+
(b) the radius was re-recorded from a later observed diff (source == 'observed' and
|
|
25
|
+
computed_at strictly greater than the event's at). These helpers implement only the narrower
|
|
26
|
+
check Layer 1 needs -- latest-event selection plus disjunct (b), which is ordinal string
|
|
27
|
+
comparison and needs no glob matcher. Omitting disjunct (a) can only report unresolved where
|
|
28
|
+
Python reports resolved, the fail-closed direction, and the parent hook's finding-file
|
|
29
|
+
allowance keeps that from deadlocking review. The cross-runtime seam test in
|
|
30
|
+
tests/scripts/claude-hooks/enforce-parallel-drift-gate-helpers.Tests.ps1 runs both runtimes
|
|
31
|
+
over one shared checkpoint-state table and fails when they diverge.
|
|
32
|
+
|
|
33
|
+
Disjunct (b)'s ordinal comparison is gated on both timestamps carrying the canonical
|
|
34
|
+
yyyy-MM-ddTHH-mm shape, because an ungated ordinal comparison fails open: '-' (0x2D) sorts
|
|
35
|
+
below ':' (0x3A), so a colon-bearing computed_at such as 2026-01-09T10:00:00Z compares
|
|
36
|
+
greater than the hyphen-bearing at 2026-01-09T10-00 even though the two name the same
|
|
37
|
+
instant, and would resolve the drift with no later diff (issue #446 remediation cycle 1,
|
|
38
|
+
finding F8-N4). A non-conforming value on either side is unresolved.
|
|
39
|
+
|
|
40
|
+
This script is dot-sourced by .claude/hooks/enforce-parallel-drift-gate.ps1. It contains no
|
|
41
|
+
entrypoint logic, so dot-sourcing it in tests has no side effects.
|
|
42
|
+
|
|
43
|
+
.NOTES
|
|
44
|
+
PowerShell 7+, no module dependencies. Parent hook:
|
|
45
|
+
.claude/hooks/enforce-parallel-drift-gate.ps1.
|
|
46
|
+
#>
|
|
47
|
+
[CmdletBinding()]
|
|
48
|
+
param()
|
|
49
|
+
|
|
50
|
+
# The one blast_radius.source member the narrowed Layer 1 resolution disjunct accepts. Read
|
|
51
|
+
# only by Test-ParallelDriftGateEventResolved, so it travels with these helpers.
|
|
52
|
+
$script:ObservedRadiusSource = 'observed'
|
|
53
|
+
|
|
54
|
+
# The canonical timestamp shape both sides of the disjunct (b) comparison must carry. The
|
|
55
|
+
# pattern text is character-identical to CANONICAL_TIMESTAMP_RE in
|
|
56
|
+
# scripts/dev_tools/_parallel_drift_shape.py, so the two runtimes accept the same value set.
|
|
57
|
+
$script:CanonicalTimestampPattern = '^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}$'
|
|
58
|
+
|
|
59
|
+
function Test-ParallelDriftGateItemKey {
|
|
60
|
+
<#
|
|
61
|
+
.SYNOPSIS
|
|
62
|
+
Report whether a checkpoint value is a positive, non-boolean integer issue_num,
|
|
63
|
+
mirroring is_positive_integer in scripts/dev_tools/_parallel_state_common.py.
|
|
64
|
+
#>
|
|
65
|
+
[CmdletBinding()]
|
|
66
|
+
[OutputType([bool])]
|
|
67
|
+
param([AllowNull()] $Value)
|
|
68
|
+
|
|
69
|
+
# A boolean in a numeric slot is malformed data, not a value to coerce.
|
|
70
|
+
if ($Value -is [bool] -or -not ($Value -is [int] -or $Value -is [long])) {
|
|
71
|
+
return $false
|
|
72
|
+
}
|
|
73
|
+
return ([long]$Value -gt 0)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Test-ParallelDriftGateText {
|
|
77
|
+
<#
|
|
78
|
+
.SYNOPSIS
|
|
79
|
+
Report whether a checkpoint value is a string carrying a non-space character,
|
|
80
|
+
mirroring is_non_empty_string in scripts/dev_tools/_parallel_state_common.py.
|
|
81
|
+
#>
|
|
82
|
+
[CmdletBinding()]
|
|
83
|
+
[OutputType([bool])]
|
|
84
|
+
param([AllowNull()] $Value)
|
|
85
|
+
|
|
86
|
+
return ($Value -is [string]) -and (-not [string]::IsNullOrWhiteSpace([string]$Value))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function Test-ParallelDriftGateCanonicalTimestamp {
|
|
90
|
+
<#
|
|
91
|
+
.SYNOPSIS
|
|
92
|
+
Report whether a checkpoint value carries the canonical yyyy-MM-ddTHH-mm timestamp
|
|
93
|
+
shape, mirroring CANONICAL_TIMESTAMP_RE in scripts/dev_tools/_parallel_drift_shape.py.
|
|
94
|
+
.DESCRIPTION
|
|
95
|
+
The match is case-sensitive (-cmatch) so the literal 'T' separator is required exactly as
|
|
96
|
+
Python's case-sensitive re.match requires it; a lowercase 't' is non-conforming in both
|
|
97
|
+
runtimes. A non-string, blank, truncated, or differently punctuated value reports $false.
|
|
98
|
+
#>
|
|
99
|
+
[CmdletBinding()]
|
|
100
|
+
[OutputType([bool])]
|
|
101
|
+
param([AllowNull()] $Value)
|
|
102
|
+
|
|
103
|
+
if (-not (Test-ParallelDriftGateText -Value $Value)) {
|
|
104
|
+
return $false
|
|
105
|
+
}
|
|
106
|
+
return ([string]$Value -cmatch $script:CanonicalTimestampPattern)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function Test-ParallelDriftGateEventRecord {
|
|
110
|
+
<#
|
|
111
|
+
.SYNOPSIS
|
|
112
|
+
Report whether one drift_events[] entry is well formed in the three fields this
|
|
113
|
+
derivation reads.
|
|
114
|
+
.DESCRIPTION
|
|
115
|
+
item_key must resolve as an issue_num and at must be non-empty. escaped_paths must be a
|
|
116
|
+
non-empty list of non-empty strings: F3 invariant 18 rejects a zero-escape event.
|
|
117
|
+
#>
|
|
118
|
+
[CmdletBinding()]
|
|
119
|
+
[OutputType([bool])]
|
|
120
|
+
param([AllowNull()] $Record)
|
|
121
|
+
|
|
122
|
+
if ($null -eq $Record -or
|
|
123
|
+
-not (Test-ParallelDriftGateItemKey -Value $Record.item_key) -or
|
|
124
|
+
-not (Test-ParallelDriftGateText -Value $Record.at) -or
|
|
125
|
+
$Record.escaped_paths -isnot [System.Collections.IList]) {
|
|
126
|
+
return $false
|
|
127
|
+
}
|
|
128
|
+
$escaped = @($Record.escaped_paths)
|
|
129
|
+
if ($escaped.Count -eq 0) {
|
|
130
|
+
return $false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# One blank or non-string entry fails the whole list, matching is_string_list.
|
|
134
|
+
foreach ($path in $escaped) {
|
|
135
|
+
if (-not (Test-ParallelDriftGateText -Value $path)) {
|
|
136
|
+
return $false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return $true
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function Get-ParallelDriftGateLatestEventMap {
|
|
143
|
+
<#
|
|
144
|
+
.SYNOPSIS
|
|
145
|
+
Reduce drift_events[] to the latest event timestamp of each item key, returning an
|
|
146
|
+
OrderedDictionary with Malformed (bool) and LatestAt (item key to at).
|
|
147
|
+
.DESCRIPTION
|
|
148
|
+
Latest means the greatest at, ordinally compared so the ranking matches Python's
|
|
149
|
+
string ordering, with ties broken by append order so the later-appended record wins.
|
|
150
|
+
One malformed entry anywhere makes the whole log unreadable, matching
|
|
151
|
+
has_unresolved_drift's malformed-log verdict. A $null checkpoint is unreadable too.
|
|
152
|
+
#>
|
|
153
|
+
[CmdletBinding()]
|
|
154
|
+
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
155
|
+
param([AllowNull()] $Checkpoint)
|
|
156
|
+
|
|
157
|
+
$latestAt = @{}
|
|
158
|
+
if ($null -eq $Checkpoint) {
|
|
159
|
+
return [ordered]@{ Malformed = $true; LatestAt = $latestAt }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# An absent drift_events key is the pre-drift checkpoint shape: no events, no drift. A
|
|
163
|
+
# present but non-list value cannot be reduced, so it fails closed.
|
|
164
|
+
if (@($Checkpoint.PSObject.Properties.Name) -notcontains 'drift_events') {
|
|
165
|
+
return [ordered]@{ Malformed = $false; LatestAt = $latestAt }
|
|
166
|
+
}
|
|
167
|
+
if ($Checkpoint.drift_events -isnot [System.Collections.IList]) {
|
|
168
|
+
return [ordered]@{ Malformed = $true; LatestAt = $latestAt }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# Walk the append-ordered log once so each item's latest event is resolved in a single
|
|
172
|
+
# pass; a malformed entry aborts the whole derivation. Replacing the record on a comparison
|
|
173
|
+
# of zero is what makes append order the tie-break, matching Python's (at, index) rank.
|
|
174
|
+
foreach ($record in @($Checkpoint.drift_events)) {
|
|
175
|
+
if (-not (Test-ParallelDriftGateEventRecord -Record $record)) {
|
|
176
|
+
return [ordered]@{ Malformed = $true; LatestAt = @{} }
|
|
177
|
+
}
|
|
178
|
+
$key = [long]$record.item_key
|
|
179
|
+
$atText = [string]$record.at
|
|
180
|
+
$comparison = 1
|
|
181
|
+
if ($latestAt.ContainsKey($key)) {
|
|
182
|
+
$comparison = [string]::CompareOrdinal($atText, [string]$latestAt[$key])
|
|
183
|
+
}
|
|
184
|
+
if ($comparison -ge 0) {
|
|
185
|
+
$latestAt[$key] = $atText
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return [ordered]@{ Malformed = $false; LatestAt = $latestAt }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function Get-ParallelDriftGateItemRadiusMap {
|
|
192
|
+
<#
|
|
193
|
+
.SYNOPSIS
|
|
194
|
+
Index the readable blast_radius blocks of items[] by item key.
|
|
195
|
+
.DESCRIPTION
|
|
196
|
+
An item with an unreadable issue_num or a non-object blast_radius is skipped rather
|
|
197
|
+
than rejected: the caller treats absence as unresolved (fail closed), and shape
|
|
198
|
+
reporting belongs to the checkpoint validator.
|
|
199
|
+
#>
|
|
200
|
+
[CmdletBinding()]
|
|
201
|
+
[OutputType([hashtable])]
|
|
202
|
+
param([AllowNull()] $Checkpoint)
|
|
203
|
+
|
|
204
|
+
$radii = @{}
|
|
205
|
+
if ($null -eq $Checkpoint -or (@($Checkpoint.PSObject.Properties.Name) -notcontains 'items')) {
|
|
206
|
+
return $radii
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# Collect only the item records whose key and radius are both readable.
|
|
210
|
+
foreach ($item in @($Checkpoint.items)) {
|
|
211
|
+
if ($null -eq $item -or -not (Test-ParallelDriftGateItemKey -Value $item.issue_num)) {
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
if ($item.blast_radius -is [System.Management.Automation.PSCustomObject]) {
|
|
215
|
+
$radii[[long]$item.issue_num] = $item.blast_radius
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return $radii
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function Test-ParallelDriftGateEventResolved {
|
|
222
|
+
<#
|
|
223
|
+
.SYNOPSIS
|
|
224
|
+
Apply the re-recorded-radius resolution disjunct to one item's latest drift event.
|
|
225
|
+
.DESCRIPTION
|
|
226
|
+
The narrowed Layer 1 check described in the script header: only the disjunct that
|
|
227
|
+
needs no glob matcher is evaluated, a radius re-recorded from a diff taken after the
|
|
228
|
+
event (source == 'observed' and computed_at strictly greater than the event's at).
|
|
229
|
+
Comparisons are ordinal and case-sensitive so the verdict matches Python's. A missing
|
|
230
|
+
or unreadable radius is unresolved (fail closed).
|
|
231
|
+
|
|
232
|
+
Both computed_at and At must satisfy the canonical yyyy-MM-ddTHH-mm pattern before the
|
|
233
|
+
ordinal comparison runs. Without that gate the comparison fails open on a
|
|
234
|
+
differently punctuated timestamp, as the script header records. Either value being
|
|
235
|
+
non-conforming yields $false, matching is_later_canonical_timestamp in
|
|
236
|
+
scripts/dev_tools/_parallel_drift_shape.py.
|
|
237
|
+
#>
|
|
238
|
+
[CmdletBinding()]
|
|
239
|
+
[OutputType([bool])]
|
|
240
|
+
param(
|
|
241
|
+
[AllowNull()] $Radius,
|
|
242
|
+
[Parameter(Mandatory)][AllowEmptyString()][string] $At
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if ($null -eq $Radius -or (([string]$Radius.source) -cne $script:ObservedRadiusSource)) {
|
|
246
|
+
return $false
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Gate the ordinal comparison on both sides conforming; a non-conforming value on either
|
|
250
|
+
# side is unresolved rather than compared against a differently shaped string.
|
|
251
|
+
if (-not (Test-ParallelDriftGateCanonicalTimestamp -Value $Radius.computed_at)) {
|
|
252
|
+
return $false
|
|
253
|
+
}
|
|
254
|
+
if (-not (Test-ParallelDriftGateCanonicalTimestamp -Value $At)) {
|
|
255
|
+
return $false
|
|
256
|
+
}
|
|
257
|
+
return ([string]::CompareOrdinal([string]$Radius.computed_at, $At) -gt 0)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function Get-ParallelDriftGateUnresolvedState {
|
|
261
|
+
<#
|
|
262
|
+
.SYNOPSIS
|
|
263
|
+
Derive the item keys whose latest drift event is still unresolved, returning an
|
|
264
|
+
OrderedDictionary with Malformed (bool), UnresolvedItemKeys (long[], ascending), and
|
|
265
|
+
LatestAt (item key to the latest event's at).
|
|
266
|
+
.DESCRIPTION
|
|
267
|
+
The PowerShell counterpart of unresolved_drift_item_keys in
|
|
268
|
+
scripts/dev_tools/parallel_drift_detection.py, narrowed as the script header records.
|
|
269
|
+
Malformed reports the case the Python derivation reports by raising, which
|
|
270
|
+
has_unresolved_drift treats as unresolved.
|
|
271
|
+
|
|
272
|
+
LatestAt is surfaced rather than kept internal so the parent hook's decision path can
|
|
273
|
+
bind a finding file to the CURRENT unresolved drift event without deriving the latest
|
|
274
|
+
event a second time (issue #446 remediation cycle 1, finding F8-N3). It is the same map
|
|
275
|
+
Get-ParallelDriftGateLatestEventMap produced, passed through unchanged, and it is empty
|
|
276
|
+
whenever Malformed is $true because an unreadable log yields no trustworthy timestamp.
|
|
277
|
+
#>
|
|
278
|
+
[CmdletBinding()]
|
|
279
|
+
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
280
|
+
param([AllowNull()] $Checkpoint)
|
|
281
|
+
|
|
282
|
+
$latestState = Get-ParallelDriftGateLatestEventMap -Checkpoint $Checkpoint
|
|
283
|
+
if ($latestState.Malformed) {
|
|
284
|
+
return [ordered]@{ Malformed = $true; UnresolvedItemKeys = [long[]]@(); LatestAt = @{} }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
# Keep the drifted items the resolution disjunct does not clear, sorted so the result is
|
|
288
|
+
# deterministic and directly comparable with the Python derivation.
|
|
289
|
+
$radii = Get-ParallelDriftGateItemRadiusMap -Checkpoint $Checkpoint
|
|
290
|
+
$unresolved = [System.Collections.Generic.List[long]]::new()
|
|
291
|
+
foreach ($itemKey in @($latestState.LatestAt.Keys)) {
|
|
292
|
+
$radius = if ($radii.ContainsKey($itemKey)) { $radii[$itemKey] } else { $null }
|
|
293
|
+
if (-not (Test-ParallelDriftGateEventResolved -Radius $radius -At ([string]$latestState.LatestAt[$itemKey]))) {
|
|
294
|
+
$unresolved.Add([long]$itemKey)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return [ordered]@{
|
|
298
|
+
Malformed = $false
|
|
299
|
+
UnresolvedItemKeys = [long[]]@($unresolved | Sort-Object)
|
|
300
|
+
LatestAt = $latestState.LatestAt
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Pre-tool-use hook that is the Layer 1 per-call deterrent for the parallel drift gate.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
Invoked by the Claude Code PreToolUse hook on the "Agent" matcher. Activates only when
|
|
7
|
+
CLAUDE_TOOL_INPUT.subagent_type == "feature-review" and the prompt carries the
|
|
8
|
+
parallel-mode kickoff marker defined in the "Parallel-Mode Kickoff Parameter" section
|
|
9
|
+
of .claude/skills/parallel-orchestrate/SKILL.md, matched byte-for-byte (ordinal).
|
|
10
|
+
|
|
11
|
+
Decision procedure: resolve the target item's feature folder from the prompt by scanning
|
|
12
|
+
for a docs/features/active/<token> path, the shape the parallel kickoff contract emits
|
|
13
|
+
for exactly this purpose (adapted from enforce-epic-wave-barrier.ps1's
|
|
14
|
+
Find-EpicWaveBarrierFeatureFolderFromPrompt: longest match wins, a .md-suffixed match
|
|
15
|
+
uses its parent directory); read the parallel checkpoint and locate the items[] record
|
|
16
|
+
whose feature_folder basename matches; derive which item keys have an unresolved latest
|
|
17
|
+
drift event; deny with PARALLEL_DRIFT_GATE_BLOCKED when the resolved item is one of them
|
|
18
|
+
and no synthetic Blocking finding file dated at or after that item's latest drift event has
|
|
19
|
+
been written. Allowed: a non-feature-review target, a prompt without the marker, a resolved
|
|
20
|
+
latest event, and an unresolved event whose finding file is dated at or after it, so the
|
|
21
|
+
R1-R5 remediation review is never deadlocked. A missing or unreadable checkpoint, an
|
|
22
|
+
unresolvable target item, and an unreadable drift log that yields no latest event timestamp
|
|
23
|
+
all deny (fail-closed).
|
|
24
|
+
|
|
25
|
+
PRESENCE GATING ONLY: checkpoint-state reads plus exactly one finding-file existence
|
|
26
|
+
check through the finding-presence seam. No git command, no diff computation, and no
|
|
27
|
+
path-glob matching; all path-matching semantics stay in the single Python
|
|
28
|
+
implementation, scripts/dev_tools/parallel_drift_detection.py. The finding-presence check
|
|
29
|
+
additionally requires the matched name's embedded yyyy-MM-ddTHH-mm timestamp to be
|
|
30
|
+
ordinally at or after the current drift event's at, so a finding from an earlier
|
|
31
|
+
remediation cycle does not open the gate; that narrowing is substring extraction plus
|
|
32
|
+
CompareOrdinal over directory entry NAMES and reads no file content.
|
|
33
|
+
|
|
34
|
+
Resolution semantics are owned by Python, not reimplemented here. That module's
|
|
35
|
+
unresolved_drift_item_keys derives resolution from two disjuncts: (a) the recorded
|
|
36
|
+
radius widened to cover every escaped path, evaluated with F1's is_path_subsumed glob
|
|
37
|
+
semantics; or (b) the radius was re-recorded from a later observed diff (source ==
|
|
38
|
+
'observed' and computed_at strictly greater than the event's at). This hook implements
|
|
39
|
+
only the narrower check Layer 1 needs -- latest-event selection plus disjunct (b), which
|
|
40
|
+
is ordinal string comparison and needs no glob matcher. Omitting disjunct (a) can only
|
|
41
|
+
report unresolved where Python reports resolved, the fail-closed direction, and the
|
|
42
|
+
finding-file allowance keeps that from deadlocking review. The cross-runtime seam test in
|
|
43
|
+
tests/scripts/claude-hooks/enforce-parallel-drift-gate-helpers.Tests.ps1 runs both runtimes
|
|
44
|
+
over one shared checkpoint-state table and fails when they diverge. Layer 2, the
|
|
45
|
+
retrospective backstop, is the PARALLEL_DRIFT_GATE_VIOLATION invariant in
|
|
46
|
+
scripts/dev_tools/_parallel_orchestrator_state_drift.py.
|
|
47
|
+
|
|
48
|
+
.NOTES
|
|
49
|
+
PowerShell 7+, no module dependencies. Both read boundaries -- the checkpoint read and the
|
|
50
|
+
finding-file existence check -- are injectable wrapper functions, so tests mock them
|
|
51
|
+
without writing temporary files.
|
|
52
|
+
|
|
53
|
+
The eight shape-and-derivation helpers this hook calls live in the dot-sourced sibling
|
|
54
|
+
module .claude/hooks/enforce-parallel-drift-gate-helpers.ps1, together with the
|
|
55
|
+
$script:ObservedRadiusSource and $script:CanonicalTimestampPattern constants they alone
|
|
56
|
+
read. They were split out to restore
|
|
57
|
+
file-size headroom (issue #446 remediation cycle 1, finding F8-N10); the split was a pure
|
|
58
|
+
move with no behaviour change. This file keeps the two read seams, the prompt and item
|
|
59
|
+
resolution, and the decision path.
|
|
60
|
+
#>
|
|
61
|
+
[CmdletBinding()]
|
|
62
|
+
param()
|
|
63
|
+
|
|
64
|
+
# Dot-source the shape-and-derivation helpers. Guarded so a missing file produces a clear error
|
|
65
|
+
# and so dot-sourcing this hook in tests loads the helpers too.
|
|
66
|
+
$script:ParallelDriftGateHelpersPath = Join-Path $PSScriptRoot 'enforce-parallel-drift-gate-helpers.ps1'
|
|
67
|
+
. $script:ParallelDriftGateHelpersPath
|
|
68
|
+
|
|
69
|
+
$script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json'
|
|
70
|
+
$script:ParallelModeMarker = 'Parallel mode: true'
|
|
71
|
+
$script:ReviewSubagentType = 'feature-review'
|
|
72
|
+
$script:ActiveFeatureRoot = 'docs/features/active'
|
|
73
|
+
$script:FindingFilePrefix = 'remediation-inputs.'
|
|
74
|
+
$script:FindingFileSuffix = '.md'
|
|
75
|
+
|
|
76
|
+
# Character length of the canonical yyyy-MM-ddTHH-mm timestamp a finding file name embeds
|
|
77
|
+
# immediately after the prefix. Read only by Test-ParallelDriftFindingPresent, which takes the
|
|
78
|
+
# substring at that fixed offset rather than matching a pattern against the path.
|
|
79
|
+
$script:FindingFileStampLength = 16
|
|
80
|
+
|
|
81
|
+
function Get-ParallelDriftGateCheckpointContent {
|
|
82
|
+
<#
|
|
83
|
+
.SYNOPSIS
|
|
84
|
+
Read the raw JSON text of the parallel checkpoint, or $null when the file is absent.
|
|
85
|
+
Tests mock this function (checkpoint-read seam).
|
|
86
|
+
#>
|
|
87
|
+
[CmdletBinding()]
|
|
88
|
+
[OutputType([string])]
|
|
89
|
+
param()
|
|
90
|
+
|
|
91
|
+
if (-not (Test-Path -LiteralPath $script:ParallelCheckpointPath -PathType Leaf)) {
|
|
92
|
+
return $null
|
|
93
|
+
}
|
|
94
|
+
return (Get-Content -LiteralPath $script:ParallelCheckpointPath -Raw)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function Test-ParallelDriftFindingPresent {
|
|
98
|
+
<#
|
|
99
|
+
.SYNOPSIS
|
|
100
|
+
Report whether the item's synthetic Blocking finding file exists. Tests mock this
|
|
101
|
+
function (finding-presence seam).
|
|
102
|
+
.DESCRIPTION
|
|
103
|
+
The parallel-orchestrator writes the finding as remediation-inputs.<timestamp>.md in
|
|
104
|
+
the child's own active feature folder (flat form), reached through the item's recorded
|
|
105
|
+
worktree_path, which is optional in the schema and may be null: absence reports $false
|
|
106
|
+
so the caller fails closed. Names are compared with ordinal prefix and suffix tests, so
|
|
107
|
+
no glob matcher is involved.
|
|
108
|
+
|
|
109
|
+
The finding must correspond to the CURRENT unresolved drift event, not to any earlier
|
|
110
|
+
remediation cycle: a matched name's embedded yyyy-MM-ddTHH-mm timestamp must be
|
|
111
|
+
ordinally greater than or equal to EventAt before $true is reported (issue #446
|
|
112
|
+
remediation cycle 1, finding F8-N3). Before that narrowing, a remediation-inputs file
|
|
113
|
+
written by an unrelated earlier cycle opened the Layer 1 gate for drifted, unsurfaced
|
|
114
|
+
work.
|
|
115
|
+
|
|
116
|
+
The narrowing stays PRESENCE GATING ONLY. The timestamp is taken with Substring from
|
|
117
|
+
the fixed offset after the remediation-inputs. prefix and compared with CompareOrdinal.
|
|
118
|
+
There is no path-glob match, no git invocation, and no read of any file's CONTENT; only
|
|
119
|
+
directory entry names are inspected. A name too short to carry the substring, or whose
|
|
120
|
+
substring is not canonically formatted, reports $false, as does a non-canonical EventAt,
|
|
121
|
+
so an unreadable timestamp on either side holds the gate closed.
|
|
122
|
+
#>
|
|
123
|
+
[CmdletBinding()]
|
|
124
|
+
[OutputType([bool])]
|
|
125
|
+
param(
|
|
126
|
+
[AllowNull()][AllowEmptyString()][string] $WorktreePath,
|
|
127
|
+
[AllowNull()][AllowEmptyString()][string] $FeatureFolder,
|
|
128
|
+
[Parameter(Mandatory)][AllowEmptyString()][string] $EventAt
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if ([string]::IsNullOrWhiteSpace($WorktreePath) -or [string]::IsNullOrWhiteSpace($FeatureFolder)) {
|
|
132
|
+
return $false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# Fail closed on an unusable reference timestamp rather than comparing against it: an
|
|
136
|
+
# ordinal comparison with a differently shaped value is exactly the inversion F8-N4 closed.
|
|
137
|
+
if (-not (Test-ParallelDriftGateCanonicalTimestamp -Value $EventAt)) {
|
|
138
|
+
return $false
|
|
139
|
+
}
|
|
140
|
+
$folder = Join-Path -Path $WorktreePath -ChildPath $script:ActiveFeatureRoot -AdditionalChildPath $FeatureFolder
|
|
141
|
+
if (-not (Test-Path -LiteralPath $folder -PathType Container)) {
|
|
142
|
+
return $false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# Report on the first remediation-inputs.<timestamp>.md entry whose embedded timestamp is at
|
|
146
|
+
# or after the current event. The timestamp varies per cycle, so the name is matched by
|
|
147
|
+
# ordinal prefix and suffix and the timestamp is read at a fixed offset, not by a pattern.
|
|
148
|
+
$stampOffset = $script:FindingFilePrefix.Length
|
|
149
|
+
$stampLength = $script:FindingFileStampLength
|
|
150
|
+
foreach ($entry in @(Get-ChildItem -LiteralPath $folder -File)) {
|
|
151
|
+
$name = [string]$entry.Name
|
|
152
|
+
if (-not ($name.StartsWith($script:FindingFilePrefix, [System.StringComparison]::Ordinal) -and
|
|
153
|
+
$name.EndsWith($script:FindingFileSuffix, [System.StringComparison]::Ordinal))) {
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if ($name.Length -lt ($stampOffset + $stampLength)) {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
$stamp = $name.Substring($stampOffset, $stampLength)
|
|
160
|
+
if (-not (Test-ParallelDriftGateCanonicalTimestamp -Value $stamp)) {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
if ([string]::CompareOrdinal($stamp, $EventAt) -ge 0) {
|
|
164
|
+
return $true
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return $false
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function Find-ParallelDriftGateFeatureFolderFromPrompt {
|
|
171
|
+
<#
|
|
172
|
+
.SYNOPSIS
|
|
173
|
+
Scan a delegation prompt for docs/features/active/<...> path tokens and return the
|
|
174
|
+
longest unique match's basename, or $null when none is found.
|
|
175
|
+
#>
|
|
176
|
+
[CmdletBinding()]
|
|
177
|
+
[OutputType([string])]
|
|
178
|
+
param([Parameter(Mandatory)][AllowEmptyString()][string] $Prompt)
|
|
179
|
+
|
|
180
|
+
if (-not $Prompt) {
|
|
181
|
+
return $null
|
|
182
|
+
}
|
|
183
|
+
$matchList = [regex]::Matches($Prompt, 'docs[\\/]+features[\\/]+active[\\/]+[^\s"''`]+')
|
|
184
|
+
if ($matchList.Count -eq 0) {
|
|
185
|
+
return $null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Collect distinct normalized tokens so the longest, most specific one can be selected.
|
|
189
|
+
$unique = @{}
|
|
190
|
+
foreach ($found in $matchList) {
|
|
191
|
+
$unique[($found.Value -replace '\\', '/').TrimEnd('/')] = $true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
$best = @(@($unique.Keys) | Sort-Object -Property Length -Descending)[0]
|
|
195
|
+
if ($best -match '\.md$') {
|
|
196
|
+
$best = $best -replace '/[^/]+\.md$', ''
|
|
197
|
+
}
|
|
198
|
+
return ($best -split '/')[-1]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function Find-ParallelDriftGateItemRecord {
|
|
202
|
+
<#
|
|
203
|
+
.SYNOPSIS
|
|
204
|
+
Locate the items[] record whose feature_folder basename equals the target basename
|
|
205
|
+
resolved from the prompt, or $null when none does.
|
|
206
|
+
#>
|
|
207
|
+
[CmdletBinding()]
|
|
208
|
+
param(
|
|
209
|
+
[AllowNull()] $Checkpoint,
|
|
210
|
+
[AllowNull()][string] $FeatureFolder
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($FeatureFolder)) {
|
|
214
|
+
return $null
|
|
215
|
+
}
|
|
216
|
+
if (@($Checkpoint.PSObject.Properties.Name) -notcontains 'items') {
|
|
217
|
+
return $null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# feature_folder may be recorded as a bare basename or as a full repo-relative path, so
|
|
221
|
+
# compare on the trailing segment of the normalized value.
|
|
222
|
+
foreach ($item in @($Checkpoint.items)) {
|
|
223
|
+
if ($null -eq $item -or -not (Test-ParallelDriftGateText -Value $item.feature_folder)) {
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
$normalized = (([string]$item.feature_folder) -replace '\\', '/').TrimEnd('/')
|
|
227
|
+
if ((($normalized -split '/')[-1]) -ceq $FeatureFolder) {
|
|
228
|
+
return $item
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return $null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function Get-ParallelDriftGateAllowDecision {
|
|
235
|
+
<#
|
|
236
|
+
.SYNOPSIS
|
|
237
|
+
Build the PreToolUse allow decision payload.
|
|
238
|
+
#>
|
|
239
|
+
[CmdletBinding()]
|
|
240
|
+
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
241
|
+
param()
|
|
242
|
+
|
|
243
|
+
return [ordered]@{
|
|
244
|
+
hookSpecificOutput = [ordered]@{
|
|
245
|
+
hookEventName = 'PreToolUse'
|
|
246
|
+
permissionDecision = 'allow'
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function Get-ParallelDriftGateBlockDecision {
|
|
252
|
+
<#
|
|
253
|
+
.SYNOPSIS
|
|
254
|
+
Build the PreToolUse deny decision payload carrying the PARALLEL_DRIFT_GATE_BLOCKED
|
|
255
|
+
reason surfaced to the caller.
|
|
256
|
+
#>
|
|
257
|
+
[CmdletBinding()]
|
|
258
|
+
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
259
|
+
param([Parameter(Mandatory)][string] $Reason)
|
|
260
|
+
|
|
261
|
+
return [ordered]@{
|
|
262
|
+
hookSpecificOutput = [ordered]@{
|
|
263
|
+
hookEventName = 'PreToolUse'
|
|
264
|
+
permissionDecision = 'deny'
|
|
265
|
+
permissionDecisionReason = $Reason
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function Invoke-ParallelDriftGateDecision {
|
|
271
|
+
<#
|
|
272
|
+
.SYNOPSIS
|
|
273
|
+
Parse the raw CLAUDE_TOOL_INPUT JSON payload and return an allow-or-block decision.
|
|
274
|
+
#>
|
|
275
|
+
[CmdletBinding()]
|
|
276
|
+
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
277
|
+
param([string] $ToolInputRaw)
|
|
278
|
+
|
|
279
|
+
if (-not $ToolInputRaw) {
|
|
280
|
+
return Get-ParallelDriftGateAllowDecision
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
$toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
|
|
284
|
+
} catch {
|
|
285
|
+
throw "enforce-parallel-drift-gate hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
# Two cheap disqualifiers first: this gate governs only feature-review delegations, and
|
|
289
|
+
# only under the parallel-mode marker, matched byte-for-byte with an ordinal Contains
|
|
290
|
+
# rather than a wildcard or a culture-sensitive comparison.
|
|
291
|
+
if (([string]$toolInput.subagent_type) -cne $script:ReviewSubagentType) {
|
|
292
|
+
return Get-ParallelDriftGateAllowDecision
|
|
293
|
+
}
|
|
294
|
+
$prompt = [string]$toolInput.prompt
|
|
295
|
+
if (-not $prompt -or -not $prompt.Contains($script:ParallelModeMarker, [System.StringComparison]::Ordinal)) {
|
|
296
|
+
return Get-ParallelDriftGateAllowDecision
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
$featureFolder = Find-ParallelDriftGateFeatureFolderFromPrompt -Prompt $prompt
|
|
300
|
+
if (-not $featureFolder) {
|
|
301
|
+
return Get-ParallelDriftGateBlockDecision -Reason 'PARALLEL_DRIFT_GATE_BLOCKED: a parallel-mode feature-review delegation must reference the target item feature folder in the prompt so its drift state can be verified.'
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
$checkpointRaw = Get-ParallelDriftGateCheckpointContent
|
|
305
|
+
$checkpoint = $null
|
|
306
|
+
if (-not [string]::IsNullOrWhiteSpace($checkpointRaw)) {
|
|
307
|
+
try {
|
|
308
|
+
$checkpoint = $checkpointRaw | ConvertFrom-Json -ErrorAction Stop
|
|
309
|
+
} catch {
|
|
310
|
+
$checkpoint = $null
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if ($null -eq $checkpoint) {
|
|
314
|
+
return Get-ParallelDriftGateBlockDecision -Reason "PARALLEL_DRIFT_GATE_BLOCKED: the parallel checkpoint '$script:ParallelCheckpointPath' is missing or unreadable, so the drift state of '$featureFolder' cannot be verified."
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
$item = Find-ParallelDriftGateItemRecord -Checkpoint $checkpoint -FeatureFolder $featureFolder
|
|
318
|
+
if ($null -eq $item -or -not (Test-ParallelDriftGateItemKey -Value $item.issue_num)) {
|
|
319
|
+
return Get-ParallelDriftGateBlockDecision -Reason "PARALLEL_DRIFT_GATE_BLOCKED: no parallel checkpoint items[] record with a readable issue_num resolves to '$featureFolder', so its drift state cannot be verified."
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# A resolved or never-drifted item is allowed outright. An unresolved item is allowed only
|
|
323
|
+
# once its synthetic Blocking finding exists, so the remediation review that resolves the
|
|
324
|
+
# drift is never deadlocked by this gate.
|
|
325
|
+
$itemKey = [long]$item.issue_num
|
|
326
|
+
$driftState = Get-ParallelDriftGateUnresolvedState -Checkpoint $checkpoint
|
|
327
|
+
if (-not $driftState.Malformed -and ($driftState.UnresolvedItemKeys -notcontains $itemKey)) {
|
|
328
|
+
return Get-ParallelDriftGateAllowDecision
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
# The finding must correspond to the CURRENT event, so the allowance needs that event's at.
|
|
332
|
+
# An unreadable log carries no trustworthy timestamp, so no allowance is possible and the
|
|
333
|
+
# gate denies (fail closed) rather than falling back to bare presence.
|
|
334
|
+
$eventAt = ''
|
|
335
|
+
if ($driftState.LatestAt.ContainsKey($itemKey)) {
|
|
336
|
+
$eventAt = [string]$driftState.LatestAt[$itemKey]
|
|
337
|
+
}
|
|
338
|
+
if ($eventAt -and (Test-ParallelDriftFindingPresent -WorktreePath ([string]$item.worktree_path) -FeatureFolder $featureFolder -EventAt $eventAt)) {
|
|
339
|
+
return Get-ParallelDriftGateAllowDecision
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return Get-ParallelDriftGateBlockDecision -Reason "PARALLEL_DRIFT_GATE_BLOCKED: item $itemKey ('$featureFolder') has an unresolved radius drift event and no $script:FindingFilePrefix<timestamp>$script:FindingFileSuffix finding dated at or after that event recorded in its feature folder. The synthetic Blocking finding for the current event must be written before review proceeds, or the drift event log was unreadable."
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# Guard allows dot-sourcing in tests without executing the entrypoint.
|
|
346
|
+
if ($MyInvocation.InvocationName -eq '.') {
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
$decision = Invoke-ParallelDriftGateDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
|
|
352
|
+
} catch {
|
|
353
|
+
Write-Error $_
|
|
354
|
+
exit 1
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
$decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
|
|
358
|
+
|
|
359
|
+
exit 0
|