@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmoisan/drm-copilot-mcp",
3
- "version": "1.1.25",
3
+ "version": "1.1.26",
4
4
  "description": "Stdio MCP server exposing drm-copilot repo-automation tools.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -23,6 +23,7 @@ tools:
23
23
  - "Bash(bash .claude/skills/cleanup-merged-worktrees/scripts/cleanup-worktrees.sh*)"
24
24
  - "Bash(pwsh -NoProfile -File .claude/lib/project-file-merge/Resolve-MergeableConflict.ps1*)"
25
25
  - "Bash(pwsh -NoProfile -NonInteractive -File .claude/lib/parallel-drift/Invoke-ParallelDriftDetection.ps1*)"
26
+ - "Bash(pwsh -NoProfile -NonInteractive -File .claude/lib/parallel-admission/Invoke-ParallelAdmission.ps1*)"
26
27
  - "Bash(pwsh -NoProfile -File .claude/lib/ci-gate/Invoke-CiGateParser.ps1*)"
27
28
  - "Bash(pwsh -NoProfile -File .claude/lib/ci-gate/Invoke-MergeConfirmationParser.ps1*)"
28
29
  - "Bash(dotnet tool restore*)"
@@ -254,8 +255,9 @@ fields you write are `objective`, `route_id: "parallel"`, `parallel_slug`,
254
255
  `parallel_manifest_path`, `parallel_status_doc_path`, `mode`, `max_concurrency`,
255
256
  `completed_steps`, `next_step`, `last_updated`, `current_cohort`, `recolor_generation`,
256
257
  `cohorts[]`, `items[]` (each carrying `issue_num`, `feature_folder`, `state`, `blast_radius`,
257
- `worktree_path`, `branch_name`, `pr_number`, `pr_url`, `merge_status`, `merge_commit_sha`, and the
258
- lifecycle timestamps), and the three receipt arrays `delegation_receipts[]`, `skill_receipts[]`,
258
+ `worktree_path`, `branch_name`, `pr_number`, `pr_url`, `merge_status`, `merge_commit_sha`, the
259
+ admission fields `preparation_status`, `plan_hash`, `base_commit`, and `band_source`, and the
260
+ lifecycle timestamps), the top-level `precedence[]` list, and the three receipt arrays `delegation_receipts[]`, `skill_receipts[]`,
259
261
  and `mcp_call_receipts[]` populated with the `parallel` route's required names from
260
262
  `config/orchestration-routing.json`.
261
263
 
@@ -263,7 +265,9 @@ The checkpoint schema is owned by F3 and is defined once in
263
265
  `.claude/rules/parallel-orchestration.md`, enforced by
264
266
  `scripts/dev_tools/validate_parallel_orchestrator_state.py`. You consume that schema and
265
267
  add no field to it that the rule file does not declare.
266
- `mergeable_conflicts_resolved` is the one declared optional item field.
268
+ The declared optional item fields are `mergeable_conflicts_resolved`, `preparation_status`,
269
+ `plan_hash`, `base_commit`, and `band_source`, and the declared optional top-level field is
270
+ `precedence[]` (`## Prepared-Item Admission (issue #1047)` of the rule file).
267
271
  The `parallel-orchestrate` skill's `## Parallel-Level Checkpoint` section carries the
268
272
  full enumeration, the `merge_status` transition chain you write, and the arrays that are read-only
269
273
  to you. Validate through `mcp__drm-copilot__validate_orchestration_artifacts` with
@@ -18,6 +18,7 @@ tools:
18
18
  - "Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"
19
19
  - "Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"
20
20
  - "Bash(bash .claude/lib/bash/report-lane-assertion.sh*)"
21
+ - "Bash(pwsh -NoProfile -NonInteractive -File .claude/lib/parallel-admission/Invoke-ParallelAdmission.ps1*)"
21
22
  - "mcp__drm-copilot__validate_orchestration_artifacts"
22
23
  skills:
23
24
  - policy-compliance-order
@@ -127,10 +128,13 @@ Update `artifacts/orchestration/parallel-planner-state.json` after every complet
127
128
  `objective`, `parallel_slug`, `parallel_manifest_path`, `mode`, `max_concurrency`,
128
129
  `plan_home_branch`, `items[]`, `cohorts[]`, `conflict_edges[]`, `recolor_generation`,
129
130
  `kickoff_prompt_path`, `completed_steps`, `next_step`, and `last_updated`. Also record the
130
- `tolerated_overlaps` list returned by Get-BlastRadiusConflictEdge (tolerated-not-validated).
131
+ `tolerated_overlaps` list returned by Get-BlastRadiusConflictEdge (tolerated-not-validated) and the
132
+ top-level `precedence[]` list of operator precedence entries (tolerated-not-validated, declared in
133
+ `## Prepared-Item Admission (issue #1047)` of `.claude/rules/parallel-orchestration.md`).
131
134
 
132
135
  Each `items[]` entry carries `issue_num`, `feature_folder`, `kind`, `state`, `complexity_band`,
133
- `complexity_assessment`, `preparation_status`, `research_path`, `plan_path`, `preflight_status`, `branch_name`,
136
+ `complexity_assessment`, `preparation_status`, `research_path`, `plan_path`, `plan_hash`,
137
+ `base_commit`, `preflight_status`, `branch_name`,
134
138
  `worktree_path`, `blast_radius`, `radius_validation`, `model_routing_receipt`, and
135
139
  `topology_receipt`. No `epic_worthiness` analogue, no `depends_on` field, and no `wave` field is
136
140
  written at any level.
@@ -62,6 +62,9 @@ try { Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.ps
62
62
  try { Import-Module (Join-Path $PSScriptRoot '../lib/worktree-resolution/WorktreeItemResolution.psm1') -Force -ErrorAction Stop } catch { Add-HookDependencyFailure -Name 'WorktreeItemResolution.psm1' -ErrorRecord $_ }
63
63
  # Epic scope (issue #663): a delegation for the epic integration branch is gated against the epic checkpoint.
64
64
  try { Import-Module (Join-Path $PSScriptRoot '../lib/worktree-resolution/EpicScopeResolution.psm1') -Force -ErrorAction Stop } catch { Add-HookDependencyFailure -Name 'EpicScopeResolution.psm1' -ErrorRecord $_ }
65
+ # Cleanup consolidation delegation (issue #1026): the session-root manifest and the shared predicates.
66
+ try { Import-Module (Join-Path $PSScriptRoot '../lib/cleanup-manifest/CleanupWorktreeManifest.psm1') -Force -ErrorAction Stop } catch { Add-HookDependencyFailure -Name 'CleanupWorktreeManifest.psm1' -ErrorRecord $_ }
67
+ try { . (Join-Path $PSScriptRoot 'hook-cleanup-context.ps1') } catch { Add-HookDependencyFailure -Name 'hook-cleanup-context.ps1' -ErrorRecord $_ }
65
68
  function Get-ModelRoutingCheckpoint {
66
69
  <#
67
70
  .SYNOPSIS
@@ -244,6 +247,38 @@ function Get-ModelRoutingAuthorAssertedReason {
244
247
  return "MODEL_ROUTING_RECEIPT_BLOCKED: AUTHOR_ASSERTED_AUTOCLOSE_MISSING: the checkpoint records issue-num $issue, so the Agent(pr-author) delegation prompt must carry the line '$line' so the PR body emits '- Closes #$issue'. Add that line on its own line, or omit it only for an epic child (epic_mode true)."
245
248
  }
246
249
 
250
+ function Test-ModelRoutingCleanupDelegation {
251
+ <#
252
+ .SYNOPSIS
253
+ Return $true when a pr-author delegation is the cleanup consolidation delegation (issue #1026).
254
+ .DESCRIPTION
255
+ The prompt must carry exactly one 'branch: documentationandmemories' label and no --head
256
+ or --branch token (Test-CleanupConsolidationBranchLabel), checked first so no other
257
+ prompt reaches the manifest read. Then the session-root manifest read through
258
+ Get-CleanupWorktreeManifestContent must pass Test-CleanupManifestText with the
259
+ Get-CleanupWorktreeManifestUtcNow clock. Never throws: any error answers $false.
260
+ .PARAMETER PromptText
261
+ The delegation prompt.
262
+ .OUTPUTS
263
+ System.Boolean
264
+ #>
265
+ [CmdletBinding()]
266
+ [OutputType([bool])]
267
+ param(
268
+ [Parameter(Mandatory)][AllowEmptyString()][string] $PromptText
269
+ )
270
+
271
+ try {
272
+ if (-not (Test-CleanupConsolidationBranchLabel -PromptText $PromptText)) {
273
+ return $false
274
+ }
275
+ return (Test-CleanupManifestText -Raw (Get-CleanupWorktreeManifestContent) -UtcNow (Get-CleanupWorktreeManifestUtcNow))
276
+ }
277
+ catch {
278
+ return $false
279
+ }
280
+ }
281
+
247
282
  function Invoke-ModelRoutingReceiptDecision {
248
283
  <#
249
284
  .SYNOPSIS
@@ -328,6 +363,12 @@ function Invoke-ModelRoutingReceiptDecision {
328
363
  return $allow
329
364
  }
330
365
 
366
+ # Issue #1026: the cleanup consolidation delegation has no checkpoint by design; a fresh valid
367
+ # session-root manifest and the exact consolidation branch label stand in for the receipt.
368
+ if ($subagent -eq 'pr-author' -and $null -eq $checkpoint -and (Test-ModelRoutingCleanupDelegation -PromptText $prompt)) {
369
+ return $allow
370
+ }
371
+
331
372
  return [ordered]@{
332
373
  hookSpecificOutput = [ordered]@{
333
374
  hookEventName = 'PreToolUse'
@@ -0,0 +1,481 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Cleanup-context allow legs of the preimplementation gate (issue #1026).
4
+
5
+ .DESCRIPTION
6
+ Dot-sourced by enforce-orchestration-preimplementation-gate.ps1 on the Claude and the Codex
7
+ surface. The gate calls Get-OrchestrationCleanupContextResult only where it would otherwise
8
+ return its final PREIMPLEMENTATION_GATE_BLOCKED deny, after the epic-scope decision, the
9
+ delegation-mode branch, and the unresolved-target and ambiguous-resolution denies, so every
10
+ existing deny path, ordering, and wording is unchanged when no leg applies. Four legs:
11
+
12
+ Manifest Write - the session-root spelling of
13
+ artifacts/orchestration/cleanup-worktrees-manifest.json (the relative
14
+ literal or its join with the session root), with content that passes
15
+ Test-CleanupManifestText. An Edit carries no content and is denied.
16
+ With -AllowPatchManifest (Codex only) a patch whose single marker is
17
+ '*** Add File:' naming the manifest is admitted on its '+' lines.
18
+ Scratch Write/Edit - an absolute local path under a temporary root, outside every
19
+ registered worktree and the session root (Test-CleanupScratchPath).
20
+ Consolidation Write - an absolute path inside the consolidation worktree (porcelain branch
21
+ documentationandmemories) whose relative remainder is allowlisted,
22
+ including the pr-author receipt artifacts/pr_body_<digits>.receipt.json.
23
+ Consolidation git - every chained segment is git -C <absolute root> add or commit, every
24
+ target is the consolidation root; add takes only '--' and allowlisted
25
+ operands; commit takes only -m, --message, -F, --file, and --trailer
26
+ with values, no pathspec, and a non-empty, entirely allowlisted staged
27
+ index read through Get-OrchestrationGateStagedIndex.
28
+
29
+ Pure prefilters run before any process: the manifest leg compares the path literal; the
30
+ scratch leg requires an absolute local path under a temporary root; the consolidation Write
31
+ leg requires a /docs/, /.claude/agent-memory/, or receipt tail; the command leg requires
32
+ every segment to be the git -C add or commit shape. Only then is the listing seam
33
+ Get-OrchestrationGateWorktreeList called, and the staged-index seam only after every target
34
+ matches the consolidation root. Get-OrchestrationCleanupContextResult never throws: any
35
+ error answers Allowed $false, which keeps the gate's deny. Test-OrchestrationCleanupContextAllowed
36
+ is its Boolean wrapper.
37
+
38
+ DenySuffix, which the gate appends to its deny reason, is empty unless a leg has identified
39
+ the consolidation worktree as the target (the Write leg: the enclosing root is the
40
+ consolidation root; the git leg: every resolved target is that root) and then fails. It is
41
+ then ' Cleanup leg not applied: ' followed by one sub-code and a short detail:
42
+
43
+ CLEANUP_LEG_PATH_NOT_ALLOWLISTED - Write leg: the relative remainder is not allowlisted.
44
+ CLEANUP_LEG_STAGED_INDEX_UNREADABLE - commit: the staged-index read failed, exited
45
+ non-zero, or is not NUL-terminated.
46
+ CLEANUP_LEG_STAGED_INDEX_EMPTY - commit: the staged index lists no path.
47
+ CLEANUP_LEG_STAGED_PATH_NOT_ALLOWLISTED - commit: a staged path is not allowlisted.
48
+
49
+ A pure-prefilter rejection, the manifest and scratch legs, an empty or failed listing, a
50
+ missing consolidation record, an unresolved or other target, and any error leave DenySuffix
51
+ empty, so every other deny text is unchanged.
52
+
53
+ .NOTES
54
+ PowerShell 7+. Byte-identical at .claude/hooks, .codex/hooks, and both bundle mirrors.
55
+ Call-time dependencies loaded by each gate: Get-OrchestrationGateWorktreeList (epic-scope
56
+ sibling), ConvertFrom-HookTargetWorktreePorcelain, Get-HookTargetWorktreeEnclosingRoot, and
57
+ Get-HookTargetWorktreeRelativePath (HookTargetWorktree.psm1 on Claude, hook-target-worktree.ps1
58
+ on Codex), Get-OrchestrationCommandTarget and $script:OrchestrationTargetNonLocalPattern
59
+ (targets file), and Split-OrchestrationCommandLine, Test-OrchestrationCommandTextUnresolvable,
60
+ and ConvertTo-OrchestrationCommandToken (helpers file).
61
+ #>
62
+
63
+ try { . (Join-Path $PSScriptRoot 'hook-cleanup-context.ps1') } catch { Add-HookDependencyFailure -Name 'hook-cleanup-context.ps1' -ErrorRecord $_ }
64
+ try { . (Join-Path $PSScriptRoot 'hook-docs-only-diff.ps1') } catch { Add-HookDependencyFailure -Name 'hook-docs-only-diff.ps1' -ErrorRecord $_ }
65
+
66
+ function Get-OrchestrationGateTemporaryRoot {
67
+ <#
68
+ .SYNOPSIS
69
+ Return every accepted spelling of the operating-system temporary root (test seam).
70
+ .DESCRIPTION
71
+ The distinct non-empty values of [System.IO.Path]::GetTempPath(), TEMP, TMP, and TMPDIR.
72
+ GetTempPath() supplies the long-name spelling on Windows, while the TEMP value the
73
+ runtime hands to the Write tool can carry the 8.3 short-name spelling, so both are
74
+ accepted. Tests mock this function.
75
+ .OUTPUTS
76
+ System.String[]
77
+ #>
78
+ [CmdletBinding()]
79
+ [OutputType([string[]])]
80
+ param()
81
+
82
+ $values = [System.Collections.Generic.List[string]]::new()
83
+ foreach ($value in @([System.IO.Path]::GetTempPath(), $env:TEMP, $env:TMP, $env:TMPDIR)) {
84
+ if (-not [string]::IsNullOrWhiteSpace($value) -and $values -cnotcontains $value) {
85
+ $values.Add($value)
86
+ }
87
+ }
88
+ return [string[]]$values.ToArray()
89
+ }
90
+
91
+ function Get-OrchestrationGateStagedIndex {
92
+ <#
93
+ .SYNOPSIS
94
+ Read the staged paths of a worktree as NUL-terminated names (test seam).
95
+ .DESCRIPTION
96
+ Runs git diff --cached --name-only -z --no-renames --no-color --no-ext-diff --no-relative
97
+ in the worktree through the bounded Invoke-DocsOnlyGitExe seam, which never throws and
98
+ reports ExitCode -1 for a root that is not a directory. Tests mock this function.
99
+ .PARAMETER WorktreeRoot
100
+ The consolidation worktree root.
101
+ .OUTPUTS
102
+ System.Management.Automation.PSCustomObject with ExitCode and Output.
103
+ #>
104
+ [CmdletBinding()]
105
+ [OutputType([pscustomobject])]
106
+ param([Parameter(Mandatory)][string] $WorktreeRoot)
107
+
108
+ return (Invoke-DocsOnlyGitExe -WorktreeRoot $WorktreeRoot -GitArgs @('diff', '--cached', '--name-only', '-z', '--no-renames', '--no-color', '--no-ext-diff', '--no-relative'))
109
+ }
110
+
111
+ function Get-OrchestrationCleanupListingRecord {
112
+ <#
113
+ .SYNOPSIS
114
+ Return the porcelain records of the session root's worktree listing, or an empty array.
115
+ .OUTPUTS
116
+ System.Object[]
117
+ #>
118
+ [CmdletBinding()]
119
+ [OutputType([object[]])]
120
+ param([Parameter(Mandatory)][string] $SessionRoot)
121
+
122
+ $text = [string](Get-OrchestrationGateWorktreeList -SessionRoot $SessionRoot)
123
+ return @(ConvertFrom-HookTargetWorktreePorcelain -Text $text)
124
+ }
125
+
126
+ function ConvertTo-OrchestrationCleanupComparablePath {
127
+ <#
128
+ .SYNOPSIS
129
+ Normalize separators and upper-case a leading drive letter, so only the drive letter
130
+ is compared without case.
131
+ .OUTPUTS
132
+ System.String
133
+ #>
134
+ [CmdletBinding()]
135
+ [OutputType([string])]
136
+ param([AllowNull()][AllowEmptyString()][string] $Path)
137
+
138
+ $normalized = ConvertTo-CleanupContextPath -Path $Path
139
+ if ($normalized -match '^[A-Za-z]:') {
140
+ $normalized = $normalized.Substring(0, 1).ToUpperInvariant() + $normalized.Substring(1)
141
+ }
142
+ return $normalized
143
+ }
144
+
145
+ function Test-OrchestrationCleanupManifestPath {
146
+ <#
147
+ .SYNOPSIS
148
+ Return $true when a path is the session-root spelling of the cleanup manifest.
149
+ .OUTPUTS
150
+ System.Boolean
151
+ #>
152
+ [CmdletBinding()]
153
+ [OutputType([bool])]
154
+ param([AllowNull()][AllowEmptyString()][string] $Path, [Parameter(Mandatory)][string] $SessionRoot)
155
+
156
+ $relative = $script:CleanupContextManifestRelativePath
157
+ $candidate = ConvertTo-OrchestrationCleanupComparablePath -Path $Path
158
+ if ($candidate -ceq $relative) {
159
+ return $true
160
+ }
161
+ $root = ConvertTo-OrchestrationCleanupComparablePath -Path $SessionRoot
162
+ $joined = if ($root.EndsWith('/')) { $root + $relative } else { $root + '/' + $relative }
163
+ return ($candidate -ceq $joined)
164
+ }
165
+
166
+ function Get-OrchestrationCleanupPatchManifestContent {
167
+ <#
168
+ .SYNOPSIS
169
+ Return the added text of a patch whose single marker adds the manifest, or $null.
170
+ .DESCRIPTION
171
+ The first non-empty line must be '*** Begin Patch' and the patch must carry exactly one
172
+ file marker, an '*** Add File:' naming the manifest. The content is the '+' lines that
173
+ follow the marker, with the '+' removed, up to the next '***' line.
174
+ .OUTPUTS
175
+ System.String or $null
176
+ #>
177
+ [CmdletBinding()]
178
+ [OutputType([string])]
179
+ param([Parameter(Mandatory)][AllowEmptyString()][string] $PatchText, [Parameter(Mandatory)][string] $SessionRoot)
180
+
181
+ $markers = @(Get-OrchestrationPatchMarkerPath -PatchText $PatchText)
182
+ $lines = @($PatchText -split "`r?`n")
183
+ $markerLines = @($lines | Where-Object { $_ -cmatch '^\*\*\* (Add File|Update File|Delete File|Move to):' })
184
+ if ($markers.Count -ne 1 -or $markerLines.Count -ne 1 -or -not $markerLines[0].StartsWith('*** Add File:', [System.StringComparison]::Ordinal)) {
185
+ return $null
186
+ }
187
+ if (-not (Test-OrchestrationCleanupManifestPath -Path $markers[0] -SessionRoot $SessionRoot)) {
188
+ return $null
189
+ }
190
+ $added = [System.Collections.Generic.List[string]]::new()
191
+ $inside = $false
192
+ foreach ($line in $lines) {
193
+ if ($line.StartsWith('*** Add File:', [System.StringComparison]::Ordinal)) { $inside = $true; continue }
194
+ if (-not $inside) { continue }
195
+ if ($line.StartsWith('***', [System.StringComparison]::Ordinal)) { break }
196
+ if (-not $line.StartsWith('+', [System.StringComparison]::Ordinal)) { return $null }
197
+ $added.Add($line.Substring(1))
198
+ }
199
+ return ($added -join "`n")
200
+ }
201
+
202
+ function Test-OrchestrationCleanupScratchWrite {
203
+ <#
204
+ .SYNOPSIS
205
+ Scratch leg: an absolute local path under a temporary root and outside every worktree.
206
+ .OUTPUTS
207
+ System.Boolean
208
+ #>
209
+ [CmdletBinding()]
210
+ [OutputType([bool])]
211
+ param([Parameter(Mandatory)][string] $FilePath, [Parameter(Mandatory)][string] $SessionRoot)
212
+
213
+ if ($FilePath -match $script:OrchestrationTargetNonLocalPattern) {
214
+ return $false
215
+ }
216
+ $normalized = ConvertTo-CleanupContextPath -Path $FilePath
217
+ if ($normalized -notmatch '^([A-Za-z]:/|/(?!/))') {
218
+ return $false
219
+ }
220
+ $temporary = [string[]]@(Get-OrchestrationGateTemporaryRoot | ForEach-Object { $_ })
221
+ if (@($temporary | Where-Object { Test-CleanupContextPathUnder -Path $normalized -Root $_ }).Count -eq 0) {
222
+ return $false
223
+ }
224
+ $records = @(Get-OrchestrationCleanupListingRecord -SessionRoot $SessionRoot)
225
+ if ($records.Count -eq 0) {
226
+ return $false
227
+ }
228
+ $roots = [string[]]@($records | ForEach-Object { [string]$_.Root })
229
+ if ($null -ne (Get-HookTargetWorktreeEnclosingRoot -Path $normalized -Roots $roots)) {
230
+ return $false
231
+ }
232
+ return (Test-CleanupScratchPath -Path $FilePath -RegisteredRoot $roots -SessionRoot $SessionRoot -TemporaryRoot $temporary)
233
+ }
234
+
235
+ function Get-OrchestrationCleanupConsolidationWriteResult {
236
+ <#
237
+ .SYNOPSIS
238
+ Consolidation Write/Edit leg: an allowlisted path inside the consolidation worktree.
239
+ .DESCRIPTION
240
+ DenySuffix names CLEANUP_LEG_PATH_NOT_ALLOWLISTED only when the enclosing root is the
241
+ consolidation root; every earlier rejection leaves it empty.
242
+ .OUTPUTS
243
+ System.Management.Automation.PSCustomObject with Allowed and DenySuffix.
244
+ #>
245
+ [CmdletBinding()]
246
+ [OutputType([pscustomobject])]
247
+ param([Parameter(Mandatory)][string] $FilePath, [Parameter(Mandatory)][string] $SessionRoot)
248
+
249
+ $denied = [pscustomobject]@{ Allowed = $false; DenySuffix = '' }
250
+ if ($FilePath -match $script:OrchestrationTargetNonLocalPattern) {
251
+ return $denied
252
+ }
253
+ $normalized = ConvertTo-CleanupContextPath -Path $FilePath
254
+ if ($normalized -notmatch '^([A-Za-z]:/|/(?!/))' -or $normalized -cnotmatch '/docs/|/\.claude/agent-memory/|/artifacts/pr_body_[0-9]+\.receipt\.json$') {
255
+ return $denied
256
+ }
257
+ $records = @(Get-OrchestrationCleanupListingRecord -SessionRoot $SessionRoot)
258
+ $record = Find-CleanupConsolidationRecord -Record $records
259
+ if ($null -eq $record) {
260
+ return $denied
261
+ }
262
+ $enclosing = Get-HookTargetWorktreeEnclosingRoot -Path $normalized -Roots ([string[]]@($records | ForEach-Object { [string]$_.Root }))
263
+ if (-not [string]::Equals([string]$enclosing, [string]$record.Root, [System.StringComparison]::OrdinalIgnoreCase)) {
264
+ return $denied
265
+ }
266
+ $relative = Get-HookTargetWorktreeRelativePath -Path $normalized -WorktreeRoot $record.Root
267
+ if (Test-CleanupConsolidationRelativePath -RelativePath $relative -AllowReceipt) {
268
+ return [pscustomobject]@{ Allowed = $true; DenySuffix = '' }
269
+ }
270
+ return [pscustomobject]@{ Allowed = $false; DenySuffix = " Cleanup leg not applied: CLEANUP_LEG_PATH_NOT_ALLOWLISTED: '$relative' is not an allowlisted path in the consolidation worktree." }
271
+ }
272
+
273
+ function Test-OrchestrationCleanupSegmentShape {
274
+ <#
275
+ .SYNOPSIS
276
+ Return the subcommand of a git -C <absolute> add|commit segment whose options the leg
277
+ admits, or $null.
278
+ .DESCRIPTION
279
+ add admits only '--' and at least one allowlisted operand. commit admits only -m,
280
+ --message, -F, --file, and --trailer, each with a separate value or an '=' value, and no
281
+ pathspec.
282
+ .OUTPUTS
283
+ System.String or $null
284
+ #>
285
+ [CmdletBinding()]
286
+ [OutputType([string])]
287
+ param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Token)
288
+
289
+ if ($Token.Count -lt 4 -or $Token[0] -cne 'git' -or $Token[1] -cne '-C' -or $Token[2] -notmatch '^([A-Za-z]:/|/(?!/))') {
290
+ return $null
291
+ }
292
+ $subcommand = $Token[3]
293
+ $rest = if ($Token.Count -gt 4) { @($Token[4..($Token.Count - 1)]) } else { @() }
294
+ if ($subcommand -ceq 'add') {
295
+ $operands = @($rest | Where-Object { $_ -cne '--' })
296
+ if (($rest.Count - $operands.Count) -gt 1 -or $operands.Count -eq 0) {
297
+ return $null
298
+ }
299
+ foreach ($operand in $operands) {
300
+ if ($operand.StartsWith('-') -or -not (Test-CleanupConsolidationRelativePath -RelativePath $operand)) {
301
+ return $null
302
+ }
303
+ }
304
+ return 'add'
305
+ }
306
+ if ($subcommand -cne 'commit') {
307
+ return $null
308
+ }
309
+ $valueOptions = @('-m', '--message', '-F', '--file', '--trailer')
310
+ for ($index = 0; $index -lt $rest.Count; $index++) {
311
+ $item = $rest[$index]
312
+ if ($valueOptions -ccontains $item) {
313
+ $index++
314
+ if ($index -ge $rest.Count) {
315
+ return $null
316
+ }
317
+ continue
318
+ }
319
+ $name = if ($item.IndexOf('=') -gt 0) { $item.Substring(0, $item.IndexOf('=')) } else { '' }
320
+ if (@('--message', '--file', '--trailer') -ccontains $name) {
321
+ continue
322
+ }
323
+ return $null
324
+ }
325
+ return 'commit'
326
+ }
327
+
328
+ function Get-OrchestrationCleanupConsolidationCommandResult {
329
+ <#
330
+ .SYNOPSIS
331
+ Consolidation git leg: every segment stages or commits allowlisted paths in the
332
+ consolidation worktree.
333
+ .DESCRIPTION
334
+ DenySuffix names a CLEANUP_LEG_STAGED_* sub-code only after every target matched the
335
+ consolidation root; every earlier rejection leaves it empty.
336
+ .OUTPUTS
337
+ System.Management.Automation.PSCustomObject with Allowed and DenySuffix.
338
+ #>
339
+ [CmdletBinding()]
340
+ [OutputType([pscustomobject])]
341
+ param([Parameter(Mandatory)][string] $Command, [Parameter(Mandatory)][string] $SessionRoot)
342
+
343
+ $denied = [pscustomobject]@{ Allowed = $false; DenySuffix = '' }
344
+ if (Test-OrchestrationCommandTextUnresolvable -CommandText $Command) {
345
+ return $denied
346
+ }
347
+ $split = Split-OrchestrationCommandLine -CommandText $Command
348
+ $segments = @($split.Segments)
349
+ if (-not $split.Balanced -or $segments.Count -eq 0) {
350
+ return $denied
351
+ }
352
+ $commitRoots = [System.Collections.Generic.List[string]]::new()
353
+ foreach ($segment in $segments) {
354
+ $tokens = [string[]]@(ConvertTo-OrchestrationCommandToken -Segment $segment)
355
+ $shape = Test-OrchestrationCleanupSegmentShape -Token $tokens
356
+ if ($null -eq $shape) {
357
+ return $denied
358
+ }
359
+ if ($shape -ceq 'commit') {
360
+ $commitRoots.Add($tokens[2])
361
+ }
362
+ }
363
+
364
+ $target = Get-OrchestrationCommandTarget -Command $Command -SessionRoot $SessionRoot
365
+ if (-not $target.Resolved -or @($target.Targets).Count -eq 0) {
366
+ return $denied
367
+ }
368
+ $record = Find-CleanupConsolidationRecord -Record @(Get-OrchestrationCleanupListingRecord -SessionRoot $SessionRoot)
369
+ if ($null -eq $record) {
370
+ return $denied
371
+ }
372
+ $consolidationRoot = ConvertTo-OrchestrationCleanupComparablePath -Path $record.Root
373
+ foreach ($candidate in @($target.Targets)) {
374
+ if (-not [string]::Equals((ConvertTo-OrchestrationCleanupComparablePath -Path $candidate), $consolidationRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
375
+ return $denied
376
+ }
377
+ }
378
+ if ($commitRoots.Count -gt 0) {
379
+ $prefix = ' Cleanup leg not applied: '
380
+ $unreadable = [pscustomobject]@{ Allowed = $false; DenySuffix = "${prefix}CLEANUP_LEG_STAGED_INDEX_UNREADABLE: the staged index of the consolidation worktree could not be read as NUL-terminated names." }
381
+ $empty = [pscustomobject]@{ Allowed = $false; DenySuffix = "${prefix}CLEANUP_LEG_STAGED_INDEX_EMPTY: the staged index of the consolidation worktree lists no path." }
382
+ $staged = Get-OrchestrationGateStagedIndex -WorktreeRoot $record.Root
383
+ if ($null -eq $staged -or $staged.ExitCode -ne 0) {
384
+ return $unreadable
385
+ }
386
+ $text = [string]$staged.Output
387
+ if ($text.Length -eq 0) {
388
+ return $empty
389
+ }
390
+ if (-not $text.EndsWith([char]0)) {
391
+ return $unreadable
392
+ }
393
+ $paths = @(ConvertFrom-DocsOnlyNameList -Text $text)
394
+ if ($paths.Count -eq 0) {
395
+ return $empty
396
+ }
397
+ foreach ($path in $paths) {
398
+ if (-not (Test-CleanupConsolidationRelativePath -RelativePath $path)) {
399
+ return [pscustomobject]@{ Allowed = $false; DenySuffix = "${prefix}CLEANUP_LEG_STAGED_PATH_NOT_ALLOWLISTED: the staged path '$path' is not under docs/ or .claude/agent-memory/." }
400
+ }
401
+ }
402
+ }
403
+ return [pscustomobject]@{ Allowed = $true; DenySuffix = '' }
404
+ }
405
+
406
+ function Get-OrchestrationCleanupContextResult {
407
+ <#
408
+ .SYNOPSIS
409
+ Decide whether a call that the gate would deny is a sanctioned cleanup operation.
410
+ .DESCRIPTION
411
+ Composes the manifest, scratch, consolidation Write/Edit, and consolidation git legs of
412
+ the file header and returns Allowed with the DenySuffix rule of the file header. Never
413
+ throws: any error answers Allowed $false with an empty DenySuffix.
414
+ .PARAMETER FilePath
415
+ The Write or Edit file path; empty for a command.
416
+ .PARAMETER Content
417
+ The Write content; empty for an Edit.
418
+ .PARAMETER Command
419
+ The Bash command, or the Codex apply_patch text; empty for a path leg.
420
+ .PARAMETER AllowPatchManifest
421
+ Admit a Codex apply_patch that adds the manifest (Codex gate only).
422
+ .OUTPUTS
423
+ System.Management.Automation.PSCustomObject with Allowed and DenySuffix.
424
+ #>
425
+ [CmdletBinding()]
426
+ [OutputType([pscustomobject])]
427
+ param(
428
+ [AllowNull()][AllowEmptyString()][string] $FilePath,
429
+ [AllowNull()][AllowEmptyString()][string] $Content,
430
+ [AllowNull()][AllowEmptyString()][string] $Command,
431
+ [switch] $AllowPatchManifest
432
+ )
433
+
434
+ $denied = [pscustomobject]@{ Allowed = $false; DenySuffix = '' }
435
+ try {
436
+ $sessionRoot = (Get-Location).Path
437
+ if ($FilePath) {
438
+ if (Test-OrchestrationCleanupManifestPath -Path $FilePath -SessionRoot $sessionRoot) {
439
+ return [pscustomobject]@{ Allowed = [bool](Test-CleanupManifestText -Raw $Content); DenySuffix = '' }
440
+ }
441
+ if (Test-OrchestrationCleanupScratchWrite -FilePath $FilePath -SessionRoot $sessionRoot) {
442
+ return [pscustomobject]@{ Allowed = $true; DenySuffix = '' }
443
+ }
444
+ return (Get-OrchestrationCleanupConsolidationWriteResult -FilePath $FilePath -SessionRoot $sessionRoot)
445
+ }
446
+ if (-not $Command) {
447
+ return $denied
448
+ }
449
+ if ($AllowPatchManifest -and $Command.TrimStart().StartsWith('*** Begin Patch', [System.StringComparison]::Ordinal)) {
450
+ $patchContent = Get-OrchestrationCleanupPatchManifestContent -PatchText $Command -SessionRoot $sessionRoot
451
+ return [pscustomobject]@{ Allowed = ($null -ne $patchContent -and (Test-CleanupManifestText -Raw $patchContent)); DenySuffix = '' }
452
+ }
453
+ return (Get-OrchestrationCleanupConsolidationCommandResult -Command $Command -SessionRoot $sessionRoot)
454
+ }
455
+ catch {
456
+ Write-Debug "PREIMPL_CLEANUP_LEG_ERROR: $($_.Exception.Message)"
457
+ return $denied
458
+ }
459
+ }
460
+
461
+ function Test-OrchestrationCleanupContextAllowed {
462
+ <#
463
+ .SYNOPSIS
464
+ Return $true when a call that the gate would deny is a sanctioned cleanup operation.
465
+ .DESCRIPTION
466
+ Boolean wrapper that returns the Allowed value of Get-OrchestrationCleanupContextResult.
467
+ Never throws.
468
+ .OUTPUTS
469
+ System.Boolean
470
+ #>
471
+ [CmdletBinding()]
472
+ [OutputType([bool])]
473
+ param(
474
+ [AllowNull()][AllowEmptyString()][string] $FilePath,
475
+ [AllowNull()][AllowEmptyString()][string] $Content,
476
+ [AllowNull()][AllowEmptyString()][string] $Command,
477
+ [switch] $AllowPatchManifest
478
+ )
479
+
480
+ return [bool](Get-OrchestrationCleanupContextResult -FilePath $FilePath -Content $Content -Command $Command -AllowPatchManifest:$AllowPatchManifest).Allowed
481
+ }
@@ -24,6 +24,7 @@ try { . (Join-Path $PSScriptRoot 'enforce-orchestration-preimplementation-gate-m
24
24
  # seams, the mode deny reason, and the target resolution with its import guard (issue
25
25
  # #690), all relocated to this sibling to keep the gate inside the 500-line cap.
26
26
  try { . (Join-Path $PSScriptRoot 'enforce-orchestration-preimplementation-gate-epic-scope.ps1') } catch { Add-HookDependencyFailure -Name 'enforce-orchestration-preimplementation-gate-epic-scope.ps1' -ErrorRecord $_ }
27
+ try { . (Join-Path $PSScriptRoot 'enforce-orchestration-preimplementation-gate-cleanup.ps1') } catch { Add-HookDependencyFailure -Name 'enforce-orchestration-preimplementation-gate-cleanup.ps1' -ErrorRecord $_ }
27
28
 
28
29
  # Shared command-line parser (issue #545): per-segment scan text and structural matching.
29
30
  try { . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1') } catch { Add-HookDependencyFailure -Name 'hook-command-scanner.ps1' -ErrorRecord $_ }
@@ -411,7 +412,8 @@ function Invoke-OrchestrationPreimplementationGateDecision {
411
412
  if (Test-OrchestrationReady -Payload $checkpoint) {
412
413
  return Get-OrchestrationPreimplementationGateAllowDecision
413
414
  }
414
- return Get-OrchestrationPreimplementationGateBlockDecision -Reason 'PREIMPLEMENTATION_GATE_BLOCKED: Implementation operations require artifacts/orchestration/orchestrator-state.json to contain issue number, feature folder, route metadata, lifecycle readiness, and checkpoint state before implementation begins.'
415
+ if (($cleanup = Get-OrchestrationCleanupContextResult -FilePath $filePath -Content (Get-ClaudeHookToolInputString -ToolInput $toolInput -Name 'content') -Command $command).Allowed) { return Get-OrchestrationPreimplementationGateAllowDecision } # Cleanup-context legs (issue #1026).
416
+ return Get-OrchestrationPreimplementationGateBlockDecision -Reason ('PREIMPLEMENTATION_GATE_BLOCKED: Implementation operations require artifacts/orchestration/orchestrator-state.json to contain issue number, feature folder, route metadata, lifecycle readiness, and checkpoint state before implementation begins.' + $cleanup.DenySuffix)
415
417
  }
416
418
 
417
419
  function Invoke-OrchestrationPreimplementationGateEntryPoint {