@danmoisan/drm-copilot-mcp 1.1.25 → 1.1.26

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 (36) hide show
  1. package/package.json +1 -1
  2. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +7 -3
  3. package/resources/claude-customizations/.claude/agents/parallel-planner.md +6 -2
  4. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +41 -0
  5. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-cleanup.ps1 +481 -0
  6. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +3 -1
  7. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier-helpers.ps1 +69 -3
  8. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +2 -2
  9. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.cleanup.ps1 +246 -0
  10. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.docs-only.ps1 +1 -0
  11. package/resources/claude-customizations/.claude/hooks/hook-cleanup-context.ps1 +343 -0
  12. package/resources/claude-customizations/.claude/lib/parallel-admission/Invoke-ParallelAdmission.ps1 +626 -0
  13. package/resources/claude-customizations/.claude/lib/parallel-admission/ParallelAdmission.psm1 +508 -0
  14. package/resources/claude-customizations/.claude/lib/parallel-admission/ParallelAdmissionJson.psm1 +141 -0
  15. package/resources/claude-customizations/.claude/lib/parallel-admission/ParallelAdmissionPrecedence.psm1 +508 -0
  16. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +17 -1
  17. package/resources/claude-customizations/.claude/settings.json +1 -0
  18. package/resources/claude-customizations/.claude/skills/atomic-plan-contract/SKILL.md +1 -1
  19. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +93 -14
  20. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup-worktrees.sh +23 -10
  21. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_lib.sh +54 -46
  22. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_merge_lib.sh +234 -0
  23. package/resources/claude-customizations/.claude/skills/execution-environment/SKILL.md +15 -5
  24. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +71 -7
  25. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +26 -2
  26. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +82 -12
  27. package/resources/claude-customizations/pack-manifests/core.json +8 -0
  28. package/resources/codex-and-agents-customizations/.agents/skills/execution-environment/SKILL.md +15 -5
  29. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  30. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-cleanup.ps1 +481 -0
  31. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +4 -2
  32. package/resources/codex-and-agents-customizations/.codex/hooks/hook-cleanup-context.ps1 +343 -0
  33. package/resources/codex-and-agents-customizations/pack-manifests/core.json +2 -0
  34. package/resources/customizations/.github/skills/execution-environment/SKILL.md +15 -5
  35. package/resources/powershell/PoshQC/PoshQC.ScanConfig.psm1 +3 -1
  36. package/resources/powershell/PoshQC/PoshQC.Testing.psm1 +12 -10
@@ -0,0 +1,508 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Pure admission decisions for /parallel-add and /parallel-plan (issue #1047).
4
+
5
+ .DESCRIPTION
6
+ Holds the pure functions behind the admission helper
7
+ .claude/lib/parallel-admission/Invoke-ParallelAdmission.ps1: the closed-run
8
+ guard, preparation-stage inference from branch files, preparation-receipt
9
+ selection, the staleness verdict over the files a plan cites, complexity-band
10
+ resolution, and the admission decision that maps a stage and a verdict to the
11
+ next action.
12
+
13
+ Every function is pure: no filesystem, subprocess, network, or clock access,
14
+ and no input is mutated. The entry script owns every git call, file read, and
15
+ clock read, and passes their results in as values. Path matching reuses
16
+ Get-PlanPaths (.claude/lib/blast-radius/BlastRadius.psm1) and Test-EntryOverlap
17
+ (.claude/lib/blast-radius/BlastRadiusGlob.psm1) rather than re-deriving them.
18
+ CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
19
+ #>
20
+
21
+ Set-StrictMode -Version Latest
22
+ $ErrorActionPreference = 'Stop'
23
+
24
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath '../blast-radius/BlastRadius.psm1') -Force -ErrorAction Stop
25
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath '../blast-radius/BlastRadiusGlob.psm1') -Force -ErrorAction Stop
26
+
27
+ # The run-mode members of the parallel surface (rule `## Enum Ownership`).
28
+ $script:ModeOpen = 'open'
29
+ $script:ModeClosed = 'closed'
30
+ $script:RunMode = @($script:ModeOpen, $script:ModeClosed)
31
+
32
+ function Get-ParallelAdmissionMapValue {
33
+ <#
34
+ .SYNOPSIS
35
+ Return the value stored under Key in a dictionary, or null when the key or the map is absent.
36
+ #>
37
+ [CmdletBinding()]
38
+ [OutputType([object])]
39
+ param([AllowNull()][object] $Map, [string] $Key)
40
+
41
+ if ($Map -isnot [System.Collections.IDictionary]) { return $null }
42
+ if (-not $Map.Contains($Key)) { return $null }
43
+ return $Map[$Key]
44
+ }
45
+
46
+ function New-ParallelAdmissionRunVerdict {
47
+ <#
48
+ .SYNOPSIS
49
+ Build the closed-run guard result record.
50
+ #>
51
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Pure record constructor; no state is changed.')]
52
+ [CmdletBinding()]
53
+ [OutputType([pscustomobject])]
54
+ param([bool] $Open, [string] $Detail, [AllowNull()][object] $ManifestMode, [AllowNull()][object] $CheckpointMode)
55
+
56
+ return [pscustomobject]@{ Open = $Open; Detail = $Detail; ManifestMode = $ManifestMode; CheckpointMode = $CheckpointMode }
57
+ }
58
+
59
+ function Test-ParallelAdmissionRunOpen {
60
+ <#
61
+ .SYNOPSIS
62
+ Decide whether a parallel run accepts an addition.
63
+
64
+ .DESCRIPTION
65
+ The run is open only when the manifest mode and the checkpoint `mode` are
66
+ both `open` and no `mutations[]` entry has `op` equal to `close`. Every
67
+ other case, including a missing or unreadable mode on either side and two
68
+ modes that disagree, is closed. The check fails closed, and Detail names
69
+ the reason.
70
+
71
+ .PARAMETER ManifestMode
72
+ The manifest mode, as printed by validate-parallel-manifest.sh --print-mode.
73
+
74
+ .PARAMETER Checkpoint
75
+ The parsed parallel-orchestrator checkpoint (a dictionary), or null when it
76
+ could not be read.
77
+
78
+ .OUTPUTS
79
+ pscustomobject with Open, Detail, ManifestMode, and CheckpointMode.
80
+ #>
81
+ [CmdletBinding()]
82
+ [OutputType([pscustomobject])]
83
+ param([AllowNull()][AllowEmptyString()][string] $ManifestMode, [AllowNull()][object] $Checkpoint)
84
+
85
+ $manifest = if ($null -eq $ManifestMode) { '' } else { $ManifestMode.Trim() }
86
+ if ($script:RunMode -cnotcontains $manifest) {
87
+ return New-ParallelAdmissionRunVerdict -Open $false -Detail "manifest mode is missing or unreadable ('$manifest')" -ManifestMode $manifest -CheckpointMode $null
88
+ }
89
+ if ($Checkpoint -isnot [System.Collections.IDictionary]) {
90
+ return New-ParallelAdmissionRunVerdict -Open $false -Detail 'checkpoint is missing or is not a JSON object' -ManifestMode $manifest -CheckpointMode $null
91
+ }
92
+ $checkpointMode = Get-ParallelAdmissionMapValue -Map $Checkpoint -Key 'mode'
93
+ if ($checkpointMode -isnot [string] -or $script:RunMode -cnotcontains $checkpointMode) {
94
+ return New-ParallelAdmissionRunVerdict -Open $false -Detail 'checkpoint mode is missing or unreadable' -ManifestMode $manifest -CheckpointMode $checkpointMode
95
+ }
96
+ if ($manifest -cne $checkpointMode) {
97
+ return New-ParallelAdmissionRunVerdict -Open $false -Detail "manifest mode '$manifest' disagrees with checkpoint mode '$checkpointMode'" -ManifestMode $manifest -CheckpointMode $checkpointMode
98
+ }
99
+ if ($manifest -ceq $script:ModeClosed) {
100
+ return New-ParallelAdmissionRunVerdict -Open $false -Detail 'manifest and checkpoint mode are closed' -ManifestMode $manifest -CheckpointMode $checkpointMode
101
+ }
102
+ # Both values agree on open here.
103
+ $mutations = Get-ParallelAdmissionMapValue -Map $Checkpoint -Key 'mutations'
104
+ foreach ($mutation in @($mutations)) {
105
+ if ((Get-ParallelAdmissionMapValue -Map $mutation -Key 'op') -ceq 'close') {
106
+ return New-ParallelAdmissionRunVerdict -Open $false -Detail 'a close mutation is recorded' -ManifestMode $manifest -CheckpointMode $checkpointMode
107
+ }
108
+ }
109
+ return New-ParallelAdmissionRunVerdict -Open $true -Detail 'run is open' -ManifestMode $manifest -CheckpointMode $checkpointMode
110
+ }
111
+
112
+ function Get-ParallelAdmissionText {
113
+ <#
114
+ .SYNOPSIS
115
+ Return the text recorded for Path in FileText, or the empty string when none is recorded.
116
+ #>
117
+ [CmdletBinding()]
118
+ [OutputType([string])]
119
+ param([AllowNull()][System.Collections.IDictionary] $FileText, [string] $Path)
120
+
121
+ $value = Get-ParallelAdmissionMapValue -Map $FileText -Key $Path
122
+ if ($null -eq $value) { return '' }
123
+ return [string]$value
124
+ }
125
+
126
+ function Get-ParallelAdmissionLine {
127
+ <#
128
+ .SYNOPSIS
129
+ Split text into lines, accepting LF, CRLF, and CR terminators.
130
+ #>
131
+ [CmdletBinding()]
132
+ [OutputType([string[]], [System.Object[]])]
133
+ param([AllowEmptyString()][string] $Text)
134
+
135
+ return , [string[]]@($Text -split "`r`n|`n|`r")
136
+ }
137
+
138
+ function Test-ParallelAdmissionSpecReady {
139
+ <#
140
+ .SYNOPSIS
141
+ Report whether spec text is non-blank and carries no whole-line template placeholder.
142
+ #>
143
+ [CmdletBinding()]
144
+ [OutputType([bool])]
145
+ param([AllowEmptyString()][string] $Text, [AllowEmptyCollection()][string[]] $SpecPlaceholderLine)
146
+
147
+ if ([string]::IsNullOrWhiteSpace($Text)) { return $false }
148
+ $placeholder = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
149
+ foreach ($line in @($SpecPlaceholderLine)) {
150
+ if (-not [string]::IsNullOrWhiteSpace($line)) { [void]$placeholder.Add($line.Trim()) }
151
+ }
152
+ foreach ($line in (Get-ParallelAdmissionLine -Text $Text)) {
153
+ if ($placeholder.Contains($line.Trim())) { return $false }
154
+ }
155
+ return $true
156
+ }
157
+
158
+ function Test-ParallelAdmissionPlanReady {
159
+ <#
160
+ .SYNOPSIS
161
+ Report whether plan text carries at least one task line that is not a template task line.
162
+ #>
163
+ [CmdletBinding()]
164
+ [OutputType([bool])]
165
+ param([AllowEmptyString()][string] $Text, [AllowEmptyCollection()][string[]] $PlanTemplateTaskLine)
166
+
167
+ $template = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
168
+ foreach ($line in @($PlanTemplateTaskLine)) {
169
+ if (-not [string]::IsNullOrWhiteSpace($line)) { [void]$template.Add($line.TrimEnd()) }
170
+ }
171
+ foreach ($line in (Get-ParallelAdmissionLine -Text $Text)) {
172
+ $trimmed = $line.TrimEnd()
173
+ if ($trimmed -match '^- \[[ x]\] \[P\d+-T\d+\]' -and -not $template.Contains($trimmed)) { return $true }
174
+ }
175
+ return $false
176
+ }
177
+
178
+ function Get-ParallelAdmissionInferredStage {
179
+ <#
180
+ .SYNOPSIS
181
+ Infer how far an item's branch is prepared from the files under its feature folder.
182
+
183
+ .DESCRIPTION
184
+ Applies the stage-inference rules of the feature spec `## Data & State`.
185
+ The highest satisfied stage wins and each stage requires the one before it:
186
+
187
+ - research: a non-blank .md file under <folder>/research/;
188
+ - spec: <folder>/spec.md is non-blank and carries none of the template
189
+ placeholder lines;
190
+ - plan: the lexicographically greatest <folder>/plan.*.md carries at least
191
+ one task line that is not a template task line;
192
+ - cleared: a file under <folder>/evidence/ whose name contains `preflight`
193
+ and whose text contains `PREFLIGHT: ALL CLEAR` was committed at or after
194
+ the plan file (decision D4). Otherwise the stage is capped at plan.
195
+
196
+ .PARAMETER TreePath
197
+ Repository-relative file paths on the branch (git ls-tree output).
198
+
199
+ .PARAMETER FileText
200
+ Path-to-text map for the files the rules read; a missing entry reads as empty.
201
+
202
+ .PARAMETER CommitTime
203
+ Path-to-epoch-seconds map of each file's last commit time on the branch.
204
+
205
+ .PARAMETER SpecPlaceholderLine
206
+ Whole-line placeholder lines of the spec template.
207
+
208
+ .PARAMETER PlanTemplateTaskLine
209
+ Task lines of the plan template.
210
+
211
+ .PARAMETER FeatureFolder
212
+ The item's repository-relative feature folder.
213
+
214
+ .OUTPUTS
215
+ pscustomobject with stage, research_path, spec_path, and plan_path; a path
216
+ is null when its stage is not satisfied.
217
+ #>
218
+ [CmdletBinding()]
219
+ [OutputType([pscustomobject])]
220
+ param(
221
+ [AllowEmptyCollection()][string[]] $TreePath = @(),
222
+ [AllowNull()][System.Collections.IDictionary] $FileText,
223
+ [AllowNull()][System.Collections.IDictionary] $CommitTime,
224
+ [AllowEmptyCollection()][string[]] $SpecPlaceholderLine = @(),
225
+ [AllowEmptyCollection()][string[]] $PlanTemplateTaskLine = @(),
226
+ [string] $FeatureFolder
227
+ )
228
+
229
+ if ([string]::IsNullOrWhiteSpace($FeatureFolder)) { throw 'Get-ParallelAdmissionInferredStage requires -FeatureFolder.' }
230
+ $prefix = $FeatureFolder.Trim().TrimEnd('/') + '/'
231
+ $paths = @(@($TreePath) | Where-Object { $_ -and $_.StartsWith($prefix, [System.StringComparison]::Ordinal) } | Sort-Object -CaseSensitive)
232
+ $result = [pscustomobject]@{ stage = 'none'; research_path = $null; spec_path = $null; plan_path = $null }
233
+
234
+ $research = @($paths | Where-Object {
235
+ $_.StartsWith($prefix + 'research/', [System.StringComparison]::Ordinal) -and $_.EndsWith('.md', [System.StringComparison]::Ordinal) -and
236
+ -not [string]::IsNullOrWhiteSpace((Get-ParallelAdmissionText -FileText $FileText -Path $_))
237
+ })
238
+ if ($research.Count -eq 0) { return $result }
239
+ $result.stage = 'research'
240
+ $result.research_path = $research[-1]
241
+
242
+ $specPath = $prefix + 'spec.md'
243
+ if ($paths -cnotcontains $specPath -or -not (Test-ParallelAdmissionSpecReady -Text (Get-ParallelAdmissionText -FileText $FileText -Path $specPath) -SpecPlaceholderLine $SpecPlaceholderLine)) { return $result }
244
+ $result.stage = 'spec'
245
+ $result.spec_path = $specPath
246
+
247
+ $plans = @($paths | Where-Object {
248
+ $name = $_.Substring($prefix.Length)
249
+ -not $name.Contains('/') -and $name.StartsWith('plan.', [System.StringComparison]::Ordinal) -and $name.EndsWith('.md', [System.StringComparison]::Ordinal)
250
+ })
251
+ if ($plans.Count -eq 0) { return $result }
252
+ $planPath = $plans[-1]
253
+ if (-not (Test-ParallelAdmissionPlanReady -Text (Get-ParallelAdmissionText -FileText $FileText -Path $planPath) -PlanTemplateTaskLine $PlanTemplateTaskLine)) { return $result }
254
+ $result.stage = 'plan'
255
+ $result.plan_path = $planPath
256
+
257
+ $planTime = Get-ParallelAdmissionMapValue -Map $CommitTime -Key $planPath
258
+ if ($null -eq $planTime) { return $result }
259
+ foreach ($path in $paths) {
260
+ if (-not $path.StartsWith($prefix + 'evidence/', [System.StringComparison]::Ordinal)) { continue }
261
+ $name = $path.Substring($path.LastIndexOf('/') + 1)
262
+ if (-not $name.Contains('preflight')) { continue }
263
+ if (-not (Get-ParallelAdmissionText -FileText $FileText -Path $path).Contains('PREFLIGHT: ALL CLEAR')) { continue }
264
+ $evidenceTime = Get-ParallelAdmissionMapValue -Map $CommitTime -Key $path
265
+ if ($null -ne $evidenceTime -and [long]$evidenceTime -ge [long]$planTime) {
266
+ $result.stage = 'cleared'
267
+ return $result
268
+ }
269
+ }
270
+ return $result
271
+ }
272
+
273
+ function Select-ParallelAdmissionReceipt {
274
+ <#
275
+ .SYNOPSIS
276
+ Select the newest preparation receipt by file name.
277
+
278
+ .DESCRIPTION
279
+ Returns the path whose file name matches preparation-receipt.*.json and is
280
+ lexicographically greatest under ordinal comparison (decision D8). The
281
+ fixed-width UTC stamp makes that the most recently written receipt. Older
282
+ receipts are superseded, never deleted.
283
+
284
+ .OUTPUTS
285
+ System.String, or null when no path matches.
286
+ #>
287
+ [CmdletBinding()]
288
+ [OutputType([string])]
289
+ param([AllowEmptyCollection()][string[]] $ReceiptPath = @())
290
+
291
+ $selected = $null
292
+ $selectedName = $null
293
+ foreach ($path in @($ReceiptPath)) {
294
+ if ([string]::IsNullOrWhiteSpace($path)) { continue }
295
+ $name = $path.Substring($path.LastIndexOf('/') + 1)
296
+ if ($name -notmatch '^preparation-receipt\..+\.json$') { continue }
297
+ if ($null -eq $selectedName -or [string]::CompareOrdinal($name, $selectedName) -gt 0) {
298
+ $selected = $path
299
+ $selectedName = $name
300
+ }
301
+ }
302
+ return $selected
303
+ }
304
+
305
+ $script:StageOrder = @('none', 'research', 'spec', 'plan', 'cleared')
306
+
307
+ function Resolve-ParallelAdmissionStage {
308
+ <#
309
+ .SYNOPSIS
310
+ Combine a preparation receipt with the inferred stage.
311
+
312
+ .DESCRIPTION
313
+ A receipt takes precedence over inference. A receipt that records
314
+ `cleared` but whose plan_hash differs from the current plan blob hash is
315
+ downgraded to `plan`, so preflight runs again on the changed plan. A
316
+ receipt stage outside none|research|spec|plan|cleared throws.
317
+
318
+ .OUTPUTS
319
+ pscustomobject with stage, source (receipt or inference), and downgraded.
320
+ #>
321
+ [CmdletBinding()]
322
+ [OutputType([pscustomobject])]
323
+ param([AllowNull()][object] $Receipt, [string] $InferredStage, [AllowNull()][AllowEmptyString()][string] $CurrentPlanHash)
324
+
325
+ if ($script:StageOrder -cnotcontains $InferredStage) { throw "Inferred stage '$InferredStage' is not one of $($script:StageOrder -join ', ')." }
326
+ if ($null -eq $Receipt) {
327
+ return [pscustomobject]@{ stage = $InferredStage; source = 'inference'; downgraded = $false }
328
+ }
329
+ $stage = Get-ParallelAdmissionMapValue -Map $Receipt -Key 'stage'
330
+ if ($stage -isnot [string] -or $script:StageOrder -cnotcontains $stage) { throw "Preparation receipt stage '$stage' is not one of $($script:StageOrder -join ', ')." }
331
+ $receiptHash = Get-ParallelAdmissionMapValue -Map $Receipt -Key 'plan_hash'
332
+ if ($stage -ceq 'cleared' -and ([string]$receiptHash -cne [string]$CurrentPlanHash -or [string]::IsNullOrEmpty($CurrentPlanHash))) {
333
+ return [pscustomobject]@{ stage = 'plan'; source = 'receipt'; downgraded = $true }
334
+ }
335
+ return [pscustomobject]@{ stage = $stage; source = 'receipt'; downgraded = $false }
336
+ }
337
+
338
+ function Test-ParallelAdmissionFolderPath {
339
+ <#
340
+ .SYNOPSIS
341
+ Report whether a path or glob lies inside the item's own feature folder.
342
+ #>
343
+ [CmdletBinding()]
344
+ [OutputType([bool])]
345
+ param([string] $Path, [string] $FeatureFolder)
346
+
347
+ $folder = $FeatureFolder.Trim().TrimEnd('/')
348
+ return ($Path -ceq $folder) -or $Path.StartsWith($folder + '/', [System.StringComparison]::Ordinal)
349
+ }
350
+
351
+ function Get-ParallelAdmissionCitedPath {
352
+ <#
353
+ .SYNOPSIS
354
+ Return the paths a plan cites or declares, minus the item's own feature folder.
355
+
356
+ .DESCRIPTION
357
+ The cited set is the Get-PlanPaths extraction of the plan text plus the
358
+ declared radius paths, with every entry inside <FeatureFolder>/ (including
359
+ the feature-folder glob) removed. Entries are deduplicated and sorted
360
+ ordinally.
361
+ #>
362
+ [CmdletBinding()]
363
+ [OutputType([string[]], [System.Object[]])]
364
+ param([AllowEmptyString()][string] $PlanText = '', [AllowEmptyCollection()][string[]] $DeclaredPath = @(), [string] $FeatureFolder)
365
+
366
+ if ([string]::IsNullOrWhiteSpace($FeatureFolder)) { throw 'Get-ParallelAdmissionCitedPath requires -FeatureFolder.' }
367
+ $set = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal)
368
+ foreach ($path in @(Get-PlanPaths -PlanText $PlanText) + @($DeclaredPath)) {
369
+ if ([string]::IsNullOrWhiteSpace($path)) { continue }
370
+ if (Test-ParallelAdmissionFolderPath -Path $path -FeatureFolder $FeatureFolder) { continue }
371
+ [void]$set.Add($path)
372
+ }
373
+ return , [string[]]@($set)
374
+ }
375
+
376
+ function Get-ParallelAdmissionStaleness {
377
+ <#
378
+ .SYNOPSIS
379
+ Decide whether main changed anything the plan cites since the base commit.
380
+
381
+ .DESCRIPTION
382
+ A changed path hits when it equals a cited entry or Test-EntryOverlap
383
+ reports an overlap with one. A concrete cited path that exists at the base
384
+ and is absent on main is removed. The verdict is premise_falsified when
385
+ any cited path was removed, stale when there is any hit or the base is not
386
+ an ancestor of main (fail closed), and fresh otherwise. Changed paths in
387
+ the item's own feature folder are ignored.
388
+
389
+ .PARAMETER PresentAtBase
390
+ Concrete cited paths that exist at the base commit.
391
+
392
+ .PARAMETER PresentAtMain
393
+ Concrete cited paths that exist on main.
394
+
395
+ .OUTPUTS
396
+ pscustomobject with verdict, changed_cited_paths, and removed_cited_paths.
397
+ #>
398
+ [CmdletBinding()]
399
+ [OutputType([pscustomobject])]
400
+ param(
401
+ [AllowEmptyCollection()][string[]] $CitedPath = @(),
402
+ [AllowEmptyCollection()][string[]] $ChangedPath = @(),
403
+ [AllowEmptyCollection()][string[]] $PresentAtBase = @(),
404
+ [AllowEmptyCollection()][string[]] $PresentAtMain = @(),
405
+ [bool] $BaseIsAncestor = $true,
406
+ [string] $FeatureFolder
407
+ )
408
+
409
+ $hits = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal)
410
+ foreach ($changed in @($ChangedPath)) {
411
+ if ([string]::IsNullOrWhiteSpace($changed)) { continue }
412
+ if (-not [string]::IsNullOrWhiteSpace($FeatureFolder) -and (Test-ParallelAdmissionFolderPath -Path $changed -FeatureFolder $FeatureFolder)) { continue }
413
+ foreach ($cited in @($CitedPath)) {
414
+ if ([string]::IsNullOrWhiteSpace($cited)) { continue }
415
+ if ($changed -ceq $cited -or (Test-EntryOverlap -EntryA $changed -EntryB $cited)) {
416
+ [void]$hits.Add($changed)
417
+ break
418
+ }
419
+ }
420
+ }
421
+ $removed = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal)
422
+ foreach ($cited in @($CitedPath)) {
423
+ if ([string]::IsNullOrWhiteSpace($cited) -or $cited.IndexOfAny([char[]]'*?[') -ge 0) { continue }
424
+ if (@($PresentAtBase) -ccontains $cited -and @($PresentAtMain) -cnotcontains $cited) { [void]$removed.Add($cited) }
425
+ }
426
+ $verdict = if ($removed.Count -gt 0) { 'premise_falsified' } elseif ($hits.Count -gt 0 -or -not $BaseIsAncestor) { 'stale' } else { 'fresh' }
427
+ return [pscustomobject]@{ verdict = $verdict; changed_cited_paths = [string[]]@($hits); removed_cited_paths = [string[]]@($removed) }
428
+ }
429
+
430
+ $script:BandName = @('C1', 'C2', 'C3', 'C4')
431
+
432
+ function Resolve-ParallelAdmissionBand {
433
+ <#
434
+ .SYNOPSIS
435
+ Resolve an item's complexity band and record where it came from.
436
+
437
+ .DESCRIPTION
438
+ The caller's --band value wins (source caller); otherwise the planner
439
+ checkpoint item's complexity_band (source planner_checkpoint); otherwise
440
+ the value is null with source assessment, which tells the skill to run
441
+ `## Complexity Assessment`. A non-blank value outside C1 to C4 throws.
442
+
443
+ .OUTPUTS
444
+ pscustomobject with value and source.
445
+ #>
446
+ [CmdletBinding()]
447
+ [OutputType([pscustomobject])]
448
+ param([AllowNull()][AllowEmptyString()][string] $CallerBand, [AllowNull()][AllowEmptyString()][string] $PlannerBand)
449
+
450
+ foreach ($candidate in @(@{ Value = $CallerBand; Source = 'caller' }, @{ Value = $PlannerBand; Source = 'planner_checkpoint' })) {
451
+ if ([string]::IsNullOrWhiteSpace($candidate.Value)) { continue }
452
+ if ($script:BandName -cnotcontains $candidate.Value) { throw "Complexity band '$($candidate.Value)' from $($candidate.Source) is not one of C1, C2, C3, C4." }
453
+ return [pscustomobject]@{ value = $candidate.Value; source = $candidate.Source }
454
+ }
455
+ return [pscustomobject]@{ value = $null; source = 'assessment' }
456
+ }
457
+
458
+ $script:Verdict = @('fresh', 'stale', 'premise_falsified')
459
+
460
+ function Get-ParallelAdmissionDecision {
461
+ <#
462
+ .SYNOPSIS
463
+ Map a preparation stage and a staleness verdict to the next admission action.
464
+
465
+ .DESCRIPTION
466
+ Implements the verdict-to-action table of the feature spec `## Data & State`:
467
+
468
+ - cleared + fresh: admit_prepared, no child, no preflight, no replan;
469
+ - cleared or plan + stale: resume at preflight;
470
+ - premise_falsified: resume at plan;
471
+ - plan that never cleared preflight: resume at preflight even when fresh (D3);
472
+ - spec: resume at plan; research: resume at spec;
473
+ - none: full_preparation.
474
+
475
+ A cleared stage counts only when PreflightCleared is true; otherwise it is
476
+ handled as plan. The verdict is required for plan and cleared and ignored
477
+ below them.
478
+
479
+ .OUTPUTS
480
+ pscustomobject with resume_stage (null for admit_prepared) and next_action.
481
+ #>
482
+ [CmdletBinding()]
483
+ [OutputType([pscustomobject])]
484
+ param([string] $Stage, [AllowNull()][AllowEmptyString()][string] $Verdict, [bool] $PreflightCleared)
485
+
486
+ if ($script:StageOrder -cnotcontains $Stage) { throw "Stage '$Stage' is not one of $($script:StageOrder -join ', ')." }
487
+ $effective = if ($Stage -ceq 'cleared' -and -not $PreflightCleared) { 'plan' } else { $Stage }
488
+ switch ($effective) {
489
+ 'none' { return [pscustomobject]@{ resume_stage = 'research'; next_action = 'full_preparation' } }
490
+ 'research' { return [pscustomobject]@{ resume_stage = 'spec'; next_action = 'resume_preparation' } }
491
+ 'spec' { return [pscustomobject]@{ resume_stage = 'plan'; next_action = 'resume_preparation' } }
492
+ }
493
+ if ($script:Verdict -cnotcontains $Verdict) { throw "Staleness verdict '$Verdict' is not one of $($script:Verdict -join ', ')." }
494
+ if ($Verdict -ceq 'premise_falsified') { return [pscustomobject]@{ resume_stage = 'plan'; next_action = 'resume_preparation' } }
495
+ if ($effective -ceq 'cleared' -and $Verdict -ceq 'fresh') { return [pscustomobject]@{ resume_stage = $null; next_action = 'admit_prepared' } }
496
+ return [pscustomobject]@{ resume_stage = 'preflight'; next_action = 'resume_preparation' }
497
+ }
498
+
499
+ Export-ModuleMember -Function `
500
+ Get-ParallelAdmissionMapValue, `
501
+ Test-ParallelAdmissionRunOpen, `
502
+ Get-ParallelAdmissionInferredStage, `
503
+ Select-ParallelAdmissionReceipt, `
504
+ Resolve-ParallelAdmissionStage, `
505
+ Get-ParallelAdmissionCitedPath, `
506
+ Get-ParallelAdmissionStaleness, `
507
+ Resolve-ParallelAdmissionBand, `
508
+ Get-ParallelAdmissionDecision
@@ -0,0 +1,141 @@
1
+ <#
2
+ .SYNOPSIS
3
+ JSON reading and writing for the parallel admission helper (issue #1047).
4
+
5
+ .DESCRIPTION
6
+ Holds the pure JSON functions of .claude/lib/parallel-admission/Invoke-ParallelAdmission.ps1.
7
+ JSON is read with System.Text.Json, which keeps timestamp strings and the
8
+ integer and boolean distinction (the Invoke-ParallelDriftDetection.ps1
9
+ precedent): object keys stay case-sensitive, integers become Int64, and an
10
+ array root is not unrolled. JSON is written with ordinally sorted keys at
11
+ every depth and LF line endings, so the output is identical on every host.
12
+ Every function is pure: no filesystem, subprocess, network, or clock access.
13
+ CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
14
+ #>
15
+
16
+ Set-StrictMode -Version Latest
17
+ $ErrorActionPreference = 'Stop'
18
+
19
+ $script:AdmissionIntegerTypeName = @('System.Int16', 'System.Int32', 'System.Int64', 'System.Byte', 'System.SByte', 'System.UInt16', 'System.UInt32', 'System.UInt64')
20
+
21
+ function ConvertFrom-ParallelAdmissionJsonElement {
22
+ <#
23
+ .SYNOPSIS
24
+ Convert one System.Text.Json element: objects to ordinal hashtables, arrays to object arrays, integers to Int64.
25
+ #>
26
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseLiteralInitializerForHashtable', '', Justification = 'JSON object keys are case-sensitive; the @{} literal is case-insensitive, so an ordinal-comparer hashtable is required (issue #763 precedent).')]
27
+ [CmdletBinding()]
28
+ [OutputType([hashtable], [System.Object[]], [string], [long], [double], [bool])]
29
+ param([System.Text.Json.JsonElement] $Element)
30
+
31
+ switch ($Element.ValueKind) {
32
+ ([System.Text.Json.JsonValueKind]::Object) {
33
+ $table = [hashtable]::new([System.StringComparer]::Ordinal)
34
+ foreach ($property in $Element.EnumerateObject()) {
35
+ $table[$property.Name] = ConvertFrom-ParallelAdmissionJsonElement -Element $property.Value
36
+ }
37
+ return $table
38
+ }
39
+ ([System.Text.Json.JsonValueKind]::Array) {
40
+ $list = [System.Collections.Generic.List[object]]::new()
41
+ foreach ($member in $Element.EnumerateArray()) { $list.Add((ConvertFrom-ParallelAdmissionJsonElement -Element $member)) }
42
+ return , $list.ToArray()
43
+ }
44
+ ([System.Text.Json.JsonValueKind]::String) { return $Element.GetString() }
45
+ ([System.Text.Json.JsonValueKind]::Number) {
46
+ $integral = [long]0
47
+ if ($Element.TryGetInt64([ref] $integral)) { return $integral }
48
+ return $Element.GetDouble()
49
+ }
50
+ ([System.Text.Json.JsonValueKind]::True) { return $true }
51
+ ([System.Text.Json.JsonValueKind]::False) { return $false }
52
+ default { return $null }
53
+ }
54
+ }
55
+
56
+ function ConvertFrom-ParallelAdmissionJson {
57
+ <#
58
+ .SYNOPSIS
59
+ Parse JSON text with System.Text.Json into PowerShell values; an array root is not unrolled.
60
+ #>
61
+ [CmdletBinding()]
62
+ [OutputType([hashtable], [System.Object[]], [object])]
63
+ param([string] $Text)
64
+
65
+ $document = [System.Text.Json.JsonDocument]::Parse($Text)
66
+ try {
67
+ $value = ConvertFrom-ParallelAdmissionJsonElement -Element $document.RootElement
68
+ } finally {
69
+ $document.Dispose()
70
+ }
71
+ if ($value -is [array]) { return , $value }
72
+ return $value
73
+ }
74
+
75
+ function Write-ParallelAdmissionJsonValue {
76
+ <#
77
+ .SYNOPSIS
78
+ Write one value to a JSON writer, sorting object keys ordinally at every depth.
79
+ #>
80
+ [CmdletBinding()]
81
+ [OutputType([void])]
82
+ param([System.Text.Json.Utf8JsonWriter] $Writer, [AllowNull()][object] $Value)
83
+
84
+ # Strings and dictionaries are tested before IEnumerable, which both also implement.
85
+ if ($null -eq $Value) { $Writer.WriteNullValue(); return }
86
+ if ($Value -is [bool]) { $Writer.WriteBooleanValue($Value); return }
87
+ if ($Value -is [string]) { $Writer.WriteStringValue($Value); return }
88
+ if ($script:AdmissionIntegerTypeName -contains $Value.GetType().FullName) { $Writer.WriteNumberValue([long]$Value); return }
89
+ if ($Value -is [double] -or $Value -is [single] -or $Value -is [decimal]) { $Writer.WriteNumberValue([double]$Value); return }
90
+ if ($Value -is [System.Collections.IDictionary] -or $Value -is [System.Management.Automation.PSCustomObject]) {
91
+ $pairs = [System.Collections.Generic.SortedDictionary[string, object]]::new([System.StringComparer]::Ordinal)
92
+ if ($Value -is [System.Collections.IDictionary]) {
93
+ foreach ($key in $Value.Keys) { $pairs[[string]$key] = $Value[$key] }
94
+ } else {
95
+ foreach ($property in $Value.PSObject.Properties) { $pairs[$property.Name] = $property.Value }
96
+ }
97
+ $Writer.WriteStartObject()
98
+ foreach ($pair in $pairs.GetEnumerator()) {
99
+ $Writer.WritePropertyName($pair.Key)
100
+ Write-ParallelAdmissionJsonValue -Writer $Writer -Value $pair.Value
101
+ }
102
+ $Writer.WriteEndObject()
103
+ return
104
+ }
105
+ if ($Value -is [System.Collections.IEnumerable]) {
106
+ $Writer.WriteStartArray()
107
+ foreach ($member in $Value) { Write-ParallelAdmissionJsonValue -Writer $Writer -Value $member }
108
+ $Writer.WriteEndArray()
109
+ return
110
+ }
111
+ throw "ConvertTo-ParallelAdmissionJson cannot serialize a value of type $($Value.GetType().FullName)."
112
+ }
113
+
114
+ function ConvertTo-ParallelAdmissionJson {
115
+ <#
116
+ .SYNOPSIS
117
+ Serialize a value to indented JSON with ordinally sorted keys and LF line endings.
118
+ #>
119
+ [CmdletBinding()]
120
+ [OutputType([string])]
121
+ param([AllowNull()][object] $Value)
122
+
123
+ $stream = [System.IO.MemoryStream]::new()
124
+ $options = [System.Text.Json.JsonWriterOptions]@{ Indented = $true; Encoder = [System.Text.Encodings.Web.JavaScriptEncoder]::UnsafeRelaxedJsonEscaping }
125
+ $writer = [System.Text.Json.Utf8JsonWriter]::new($stream, $options)
126
+ try {
127
+ Write-ParallelAdmissionJsonValue -Writer $writer -Value $Value
128
+ $writer.Flush()
129
+ # The writer's indentation follows the platform newline; LF keeps the output identical on every host.
130
+ return [System.Text.Encoding]::UTF8.GetString($stream.ToArray()).Replace("`r`n", "`n")
131
+ } finally {
132
+ $writer.Dispose()
133
+ $stream.Dispose()
134
+ }
135
+ }
136
+
137
+ Export-ModuleMember -Function `
138
+ ConvertFrom-ParallelAdmissionJsonElement, `
139
+ ConvertFrom-ParallelAdmissionJson, `
140
+ Write-ParallelAdmissionJsonValue, `
141
+ ConvertTo-ParallelAdmissionJson