@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.
Files changed (31) hide show
  1. package/out/mcp-server.js +1624 -190
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/MEMORY.md +5 -1
  4. package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/feedback_commit_push_memory_before_pr.md +48 -2
  5. package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/feedback_no_sendmessage_tool.md +35 -0
  6. package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/feedback_worktree_isolation_branches_from_main.md +45 -0
  7. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +238 -0
  8. package/resources/claude-customizations/.claude/agents/parallel-planner.md +149 -0
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-invocation-origin.ps1 +23 -11
  10. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +259 -0
  11. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +499 -0
  12. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate-helpers.ps1 +302 -0
  13. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +359 -0
  14. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +244 -0
  15. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +379 -0
  16. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +491 -0
  17. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +490 -0
  18. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +429 -0
  19. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +366 -0
  20. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +184 -0
  21. package/resources/claude-customizations/.claude/settings.json +25 -0
  22. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +148 -0
  23. package/resources/claude-customizations/.claude/skills/parallel-close/SKILL.md +93 -0
  24. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +960 -0
  25. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +420 -0
  26. package/resources/claude-customizations/.claude/skills/parallel-remove/SKILL.md +176 -0
  27. package/resources/claude-customizations/.claude/skills/parallel-run/SKILL.md +56 -0
  28. package/resources/claude-customizations/pack-manifests/core.json +19 -1
  29. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  30. package/resources/config/orchestration-routing.json +22 -0
  31. 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