@danmoisan/drm-copilot-mcp 1.0.27 → 1.1.1

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 (42) hide show
  1. package/out/mcp-server.js +0 -1
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/hooks/check-powershell-test-purity.ps1 +17 -21
  4. package/resources/claude-customizations/.claude/hooks/check-python-test-purity.ps1 +17 -19
  5. package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +17 -19
  6. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +18 -19
  7. package/resources/claude-customizations/.claude/hooks/enforce-discovery-artifact-gate.ps1 +18 -19
  8. package/resources/claude-customizations/.claude/hooks/enforce-epic-invocation-origin.ps1 +54 -32
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +64 -20
  10. package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +51 -22
  11. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +56 -19
  12. package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +71 -28
  13. package/resources/claude-customizations/.claude/hooks/enforce-feature-folder-order.ps1 +68 -23
  14. package/resources/claude-customizations/.claude/hooks/enforce-mermaid-validation.ps1 +36 -24
  15. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +20 -22
  16. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +349 -0
  17. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +177 -20
  18. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +17 -20
  19. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier-helpers.ps1 +278 -0
  20. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +55 -271
  21. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +57 -22
  22. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +56 -19
  23. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +70 -27
  24. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +228 -0
  25. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +68 -225
  26. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +161 -34
  27. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +59 -22
  28. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +70 -27
  29. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +32 -18
  30. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +34 -60
  31. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusTokenShape.psm1 +187 -0
  32. package/resources/claude-customizations/.claude/lib/hook-payload/HookPayload.psm1 +494 -0
  33. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +125 -4
  34. package/resources/claude-customizations/.claude/skills/epic-plan/SKILL.md +27 -0
  35. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +28 -0
  36. package/resources/claude-customizations/config/blast-radius.json +9 -3
  37. package/resources/claude-customizations/pack-manifests/core.json +5 -0
  38. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  39. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +349 -0
  40. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +123 -6
  41. package/resources/codex-and-agents-customizations/pack-manifests/core.json +1 -0
  42. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +32 -1
@@ -4,8 +4,8 @@
4
4
 
5
5
  .DESCRIPTION
6
6
  This script is invoked by the Claude Code PreToolUse hook before any Bash
7
- command runs. It reads the tool input from the CLAUDE_TOOL_INPUT environment
8
- variable, inspects the attempted command text, and blocks direct promotion
7
+ command runs. It acquires the hook payload through the shared reader and reads
8
+ the command from the envelope's nested tool_input, then blocks direct promotion
9
9
  script execution that would bypass the repository's MCP-only promotion path.
10
10
 
11
11
  Forbidden command tokens (legacy promotion-script bypass):
@@ -29,6 +29,8 @@
29
29
  [CmdletBinding()]
30
30
  param()
31
31
 
32
+
33
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
32
34
  $script:PromotionMcpOnlyBlockedReason = 'PROMOTION_MCP_ONLY_BLOCKED: Direct Bash promotion-script execution is not allowed in agent sessions. Use the drm-copilot MCP promotion tools instead.'
33
35
 
34
36
  $script:PromotionMcpOnlyGhIssueBlockedReason = 'PROMOTION_MCP_ONLY_BLOCKED: Direct GitHub issue creation via `gh` bypasses the approved drm-copilot MCP promotion path (`mcp__drm-copilot__new_potential_entry` -> `mcp__drm-copilot__potential_to_issue` -> `mcp__drm-copilot__new_active_feature_folder`). Use those MCP tools instead.'
@@ -70,7 +72,7 @@ function Get-PromotionBypassReason {
70
72
  Returns the gh-CLI issue creation reason when a forbidden gh pattern is matched.
71
73
  Returns $null when the command is allowed.
72
74
  .PARAMETER CommandText
73
- The Bash command text extracted from CLAUDE_TOOL_INPUT.
75
+ The Bash command text extracted from the envelope's tool_input.
74
76
  .OUTPUTS
75
77
  System.String or $null.
76
78
  #>
@@ -118,7 +120,7 @@ function Test-PromotionBypassToken {
118
120
  .SYNOPSIS
119
121
  Return $true when a Bash command contains a forbidden promotion bypass pattern.
120
122
  .PARAMETER CommandText
121
- The Bash command text extracted from CLAUDE_TOOL_INPUT.
123
+ The Bash command text extracted from the envelope's tool_input.
122
124
  .OUTPUTS
123
125
  System.Boolean
124
126
  #>
@@ -183,7 +185,7 @@ function Get-PromotionMcpOnlyAllowDecision {
183
185
  function Invoke-PromotionMcpOnlyDecision {
184
186
  <#
185
187
  .SYNOPSIS
186
- Parse CLAUDE_TOOL_INPUT and return an allow-or-block decision.
188
+ Parse the PreToolUse envelope and return an allow-or-block decision.
187
189
  .PARAMETER ToolInputRaw
188
190
  The raw JSON tool payload supplied by Claude Code.
189
191
  .OUTPUTS
@@ -198,17 +200,15 @@ function Invoke-PromotionMcpOnlyDecision {
198
200
  [string] $ToolInputRaw
199
201
  )
200
202
 
201
- if (-not $ToolInputRaw) {
202
- return Get-PromotionMcpOnlyAllowDecision
203
- }
204
-
205
- try {
206
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
207
- } catch {
208
- throw "enforce-promotion-mcp-only hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
203
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
204
+ if (-not $payload.IsValid) {
205
+ return Get-PromotionMcpOnlyBlockDecision -Reason (
206
+ 'PROMOTION_MCP_ONLY_BLOCKED: payload anomaly - ' +
207
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $payload.Anomaly) +
208
+ '. The gate fails closed on an envelope it cannot read.')
209
209
  }
210
210
 
211
- $commandText = $toolInput.command
211
+ $commandText = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'command'
212
212
  if (-not $commandText) {
213
213
  return Get-PromotionMcpOnlyAllowDecision
214
214
  }
@@ -221,18 +221,55 @@ function Invoke-PromotionMcpOnlyDecision {
221
221
  return Get-PromotionMcpOnlyAllowDecision
222
222
  }
223
223
 
224
+ function Invoke-PromotionMcpOnlyEntryPoint {
225
+ <#
226
+ .SYNOPSIS
227
+ Runs the hook decision and returns the process exit code.
228
+ .DESCRIPTION
229
+ Acquires the payload through the shared reader unless the caller supplies
230
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
231
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
232
+ decision by the time control reaches here. The function does not call exit;
233
+ the thin tail converts the returned code into a process exit.
234
+ .PARAMETER ToolInputRaw
235
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
236
+ .PARAMETER ReadPayload
237
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
238
+ case without touching a console.
239
+ .OUTPUTS
240
+ System.Int32
241
+ #>
242
+ [CmdletBinding()]
243
+ [OutputType([int])]
244
+ param(
245
+ [AllowNull()]
246
+ [AllowEmptyString()]
247
+ [string] $ToolInputRaw,
248
+
249
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
250
+ )
251
+
252
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
253
+ $ToolInputRaw = [string](& $ReadPayload)
254
+ }
255
+
256
+ $decision = Invoke-PromotionMcpOnlyDecision -ToolInputRaw $ToolInputRaw
257
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
258
+
259
+ return 0
260
+ }
261
+
224
262
  # Allow dot-sourcing in tests without executing the entrypoint.
225
263
  if ($MyInvocation.InvocationName -eq '.') {
226
264
  return
227
265
  }
228
266
 
229
- try {
230
- $decision = Invoke-PromotionMcpOnlyDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
231
- } catch {
232
- Write-Error $_
233
- exit 1
267
+ # The entry point returns its [int] exit code as the last pipeline element and the
268
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
269
+ # expression and emit nothing, so the decision is written explicitly here first.
270
+ $entryPointResult = @(Invoke-PromotionMcpOnlyEntryPoint)
271
+ if ($entryPointResult.Count -gt 1) {
272
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
234
273
  }
235
274
 
236
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
237
-
238
- exit 0
275
+ exit ([int]$entryPointResult[-1])
@@ -34,6 +34,8 @@
34
34
  [CmdletBinding()]
35
35
  param()
36
36
 
37
+
38
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
37
39
  function Get-PythonBatchBudgetState {
38
40
  [CmdletBinding()]
39
41
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -162,17 +164,15 @@ function Invoke-PythonBatchBudgetHook {
162
164
  }
163
165
  )
164
166
 
165
- if (-not $ToolInputRaw) {
166
- return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
167
- }
168
-
169
- try {
170
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
171
- } catch {
172
- return Get-PythonBatchBudgetBlockDecision -Reason 'Python batch-budget hook received malformed JSON in CLAUDE_TOOL_INPUT.'
167
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
168
+ if (-not $payload.IsValid) {
169
+ return Get-PythonBatchBudgetBlockDecision -Reason (
170
+ 'Python batch-budget hook received an unreadable PreToolUse envelope: ' +
171
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $payload.Anomaly) +
172
+ '. The gate fails closed on an envelope it cannot read.')
173
173
  }
174
174
 
175
- $filePath = $toolInput.file_path
175
+ $filePath = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'file_path'
176
176
  if (-not $filePath) {
177
177
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
178
178
  }
@@ -211,28 +211,71 @@ function Invoke-PythonBatchBudgetHook {
211
211
  return $decision
212
212
  }
213
213
 
214
- if ($MyInvocation.InvocationName -eq '.') {
215
- return
216
- }
214
+ function Invoke-PythonBatchBudgetEntryPoint {
215
+ <#
216
+ .SYNOPSIS
217
+ Runs the Python batch-budget decision and returns the process exit code.
218
+ .DESCRIPTION
219
+ Wraps the dispatch logic that the hook entry point performs so it can be
220
+ exercised by unit tests. It acquires the payload through the shared reader
221
+ unless the caller supplies one, writes the compact JSON decision to the output
222
+ stream only when the decision is a deny (this hook is deny-only: an allow
223
+ decision emits nothing), and returns 0. This function does not call exit; the
224
+ thin entry-point wiring converts the returned code into a process exit.
225
+ .PARAMETER ToolInputRaw
226
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
227
+ .PARAMETER ReadPayload
228
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
229
+ case without touching a console.
230
+ #>
231
+ [CmdletBinding()]
232
+ [OutputType([int])]
233
+ param(
234
+ [AllowNull()]
235
+ [AllowEmptyString()]
236
+ [string] $ToolInputRaw,
217
237
 
218
- $sessionId = $env:CLAUDE_SESSION_ID
219
- if (-not $sessionId) {
220
- $sessionId = 'default'
221
- }
238
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
239
+ )
222
240
 
223
- $prodCap = 3
224
- $testCap = 3
225
- if ($env:CLAUDE_PYTHON_BUDGET_PROD -match '^\d+$') {
226
- $prodCap = [int]$env:CLAUDE_PYTHON_BUDGET_PROD
241
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
242
+ $ToolInputRaw = [string](& $ReadPayload)
243
+ }
244
+
245
+ $sessionId = $env:CLAUDE_SESSION_ID
246
+ if (-not $sessionId) {
247
+ $sessionId = 'default'
248
+ }
249
+
250
+ $prodCap = 3
251
+ $testCap = 3
252
+ if ($env:CLAUDE_PYTHON_BUDGET_PROD -match '^\d+$') {
253
+ $prodCap = [int]$env:CLAUDE_PYTHON_BUDGET_PROD
254
+ }
255
+ if ($env:CLAUDE_PYTHON_BUDGET_TEST -match '^\d+$') {
256
+ $testCap = [int]$env:CLAUDE_PYTHON_BUDGET_TEST
257
+ }
258
+
259
+ $decision = Invoke-PythonBatchBudgetHook -ToolInputRaw $ToolInputRaw -SessionId $sessionId -ProdCap $prodCap -TestCap $testCap
260
+ if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
261
+ $decision.Remove('state')
262
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
263
+ }
264
+
265
+ return 0
227
266
  }
228
- if ($env:CLAUDE_PYTHON_BUDGET_TEST -match '^\d+$') {
229
- $testCap = [int]$env:CLAUDE_PYTHON_BUDGET_TEST
267
+
268
+ # Guard allows dot-sourcing in tests without executing the entrypoint.
269
+ if ($MyInvocation.InvocationName -eq '.') {
270
+ return
230
271
  }
231
272
 
232
- $decision = Invoke-PythonBatchBudgetHook -ToolInputRaw $env:CLAUDE_TOOL_INPUT -SessionId $sessionId -ProdCap $prodCap -TestCap $testCap
233
- if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
234
- $decision.Remove('state')
235
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
273
+ # The entry point returns its [int] exit code as the last pipeline element and the
274
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
275
+ # expression and emit nothing, so the decision is written explicitly here first.
276
+ $entryPointResult = @(Invoke-PythonBatchBudgetEntryPoint)
277
+ if ($entryPointResult.Count -gt 1) {
278
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
236
279
  }
237
280
 
238
- exit 0
281
+ exit ([int]$entryPointResult[-1])
@@ -4,13 +4,14 @@
4
4
 
5
5
  .DESCRIPTION
6
6
  This script is invoked by the Claude Code PreToolUse hook before any Bash
7
- command is executed. It reads the proposed command string from the
8
- CLAUDE_TOOL_INPUT (or CLAUDE_HOOK_INPUT) environment variable (JSON with a
9
- 'command' field) or falls back to the first positional argument. If the
10
- command matches any blocked pattern (destructive operations such as forced
11
- deletions, forced pushes, or hard resets), the script writes a PreToolUse
12
- deny decision to stdout and exits with code 0. The deny decision uses the
13
- Claude Code PreToolUse schema:
7
+ command is executed. It acquires the hook payload through the shared reader
8
+ (.claude/lib/hook-payload/HookPayload.psm1: stdin first, then the two
9
+ environment-variable fallbacks) and reads the proposed command string from the
10
+ envelope's nested tool_input.command, or falls back to the first positional
11
+ argument. If the command matches any blocked pattern (destructive operations
12
+ such as forced deletions, forced pushes, or hard resets), the script writes a
13
+ PreToolUse deny decision to stdout and exits with code 0. The deny decision
14
+ uses the Claude Code PreToolUse schema:
14
15
 
15
16
  {"hookSpecificOutput":{"hookEventName":"PreToolUse",
16
17
  "permissionDecision":"deny","permissionDecisionReason":"<reason>"}}
@@ -20,6 +21,13 @@
20
21
  deny-path 'exit 1' are intentionally NOT used: PreToolUse fail-opens on both,
21
22
  so they would silently fail to block.
22
23
 
24
+ Deliberate exception to the fail-closed envelope policy (issue #501, AC-5): this
25
+ hook is a dangerous-command denylist, not a receipt gate, so an empty payload
26
+ remains an allow and unparseable raw text is still treated as the command text
27
+ for denylist matching. Both behaviours preserve the documented manual/CLI usage
28
+ 'pwsh -NoProfile -File validate-bash.ps1 "<command>"'. Every other PreToolUse
29
+ hook denies on those two conditions.
30
+
23
31
  .NOTES
24
32
  Compatible with PowerShell 7+.
25
33
  This script must not modify any state; it is a read-only validation gate.
@@ -30,6 +38,8 @@ param(
30
38
  [string]$CommandInput
31
39
  )
32
40
 
41
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
42
+
33
43
  function Get-BlockedBashPattern {
34
44
  [CmdletBinding()]
35
45
  [OutputType([string[]])]
@@ -119,14 +129,21 @@ function Get-BashCommandToCheck {
119
129
  )
120
130
 
121
131
  if ($ToolInputRaw) {
122
- try {
123
- $parsed = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
124
- if ($parsed.command) {
125
- return [string]$parsed.command
132
+ $parsed = ConvertFrom-ClaudeHookEnvelope -Raw $ToolInputRaw
133
+ if (-not $parsed.IsValid) {
134
+ # AC-5 exception: unparseable raw text is still treated as the command
135
+ # text, which is what the documented manual/CLI usage supplies.
136
+ if ($parsed.Anomaly -eq 'UnparseableJson') {
137
+ return [string]$ToolInputRaw
138
+ }
139
+ } else {
140
+ $extracted = Get-ClaudeHookToolInput -Envelope $parsed.Value
141
+ if ($extracted.IsValid) {
142
+ $command = Get-ClaudeHookToolInputString -ToolInput $extracted.Value -Name 'command'
143
+ if ($command) {
144
+ return $command
145
+ }
126
146
  }
127
- } catch {
128
- # If JSON parsing fails, treat the raw input as the command.
129
- return $ToolInputRaw
130
147
  }
131
148
  }
132
149
 
@@ -166,10 +183,7 @@ if ($MyInvocation.InvocationName -eq '.') {
166
183
  return
167
184
  }
168
185
 
169
- $toolInputRaw = $env:CLAUDE_TOOL_INPUT
170
- if (-not $toolInputRaw) {
171
- $toolInputRaw = $env:CLAUDE_HOOK_INPUT
172
- }
186
+ $toolInputRaw = Read-ClaudeHookRawPayload
173
187
 
174
188
  $decision = Invoke-ValidateBashDecision -ToolInputRaw $toolInputRaw -PositionalInput $CommandInput
175
189
  if ($null -ne $decision -and $decision.hookSpecificOutput.permissionDecision -eq 'deny') {
@@ -44,6 +44,16 @@ Set-StrictMode -Version Latest
44
44
  # imports no sibling.
45
45
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force
46
46
 
47
+ # Test-MultipleFeatureFolderSpan moved to BlastRadiusTokenShape.psm1, joining the
48
+ # new Test-PlaceholderMarker predicate that could not be added here: this module
49
+ # had two lines of headroom against the 500-line limit (issue #502). Both
50
+ # predicates are context-free shape tests, so they form one cohesive leaf. The
51
+ # import keeps every pre-existing call site and test source-compatible and
52
+ # introduces no cycle, because the TokenShape module imports no sibling. This
53
+ # follows the same re-import-and-re-export pattern used above for the relocated
54
+ # ordinal-sort helper.
55
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusTokenShape.psm1') -Force
56
+
47
57
  # Plan-structure patterns. The regex text mirrors the Python constants so radius
48
58
  # derivation and the plan validator can never disagree about which lines are
49
59
  # phase headings and which are tasks.
@@ -71,12 +81,6 @@ $script:KnownTopLevelSegment = @(
71
81
  # acceptance its unanchored form has; the token itself is recorded verbatim.
72
82
  $script:LineSuffixPattern = [regex]::new(':\d+$')
73
83
 
74
- # Documentation-corpus root and the index, counted after that prefix, of the
75
- # segment that names one feature folder. A glob whose wildcard reaches this
76
- # segment or any earlier one claims every feature folder in the corpus.
77
- $script:FeatureCorpusPrefix = 'docs/features/'
78
- $script:FeatureFolderSegmentIndex = 1
79
-
80
84
  # Fallback acceptance rule: a token shaped <segment>/.../<name>.<ext> counts as a
81
85
  # repository path when its final component carries one of these extensions.
82
86
  $script:RecognizedPathExtension = [System.Collections.Generic.HashSet[string]]::new(
@@ -233,60 +237,6 @@ function Get-InlineCodeToken {
233
237
  return @($token.ToArray())
234
238
  }
235
239
 
236
- function Test-MultipleFeatureFolderSpan {
237
- <#
238
- .SYNOPSIS
239
- Report whether a glob claims more than one documentation feature folder.
240
-
241
- .DESCRIPTION
242
- Port of spans_multiple_feature_folders. The documentation corpus is laid
243
- out as docs/features/<bucket>/<feature-folder>/..., so a glob whose
244
- wildcard occupies or truncates the feature-folder segment claims every
245
- feature folder in the corpus. That made two unrelated work items contend
246
- purely because both wrote documentation (issue #489). A glob carrying a
247
- complete, wildcard-free feature-folder segment claims one folder and is
248
- retained.
249
-
250
- .PARAMETER Token
251
- A wildcard-bearing token already accepted by the shape rules of
252
- Get-PathTokenKind.
253
-
254
- .OUTPUTS
255
- System.Boolean. True when the token is rooted in the documentation corpus
256
- and its wildcard reaches the feature-folder segment or any earlier one.
257
- #>
258
- [CmdletBinding()]
259
- [OutputType([bool])]
260
- param(
261
- [Parameter(Mandatory = $true)]
262
- [AllowEmptyString()]
263
- [string] $Token
264
- )
265
-
266
- if (-not $Token.StartsWith($script:FeatureCorpusPrefix,
267
- [System.StringComparison]::Ordinal)) {
268
- return $false
269
- }
270
-
271
- $segment = @($Token.Substring($script:FeatureCorpusPrefix.Length) -split '/')
272
-
273
- # A token that stops at or before the feature-folder segment has had that
274
- # segment truncated away by the wildcard, so it spans the whole corpus.
275
- if ($segment.Count -le $script:FeatureFolderSegmentIndex) {
276
- return $true
277
- }
278
-
279
- # Every segment up to and including the feature-folder name must be a literal
280
- # for the claim to resolve to exactly one folder.
281
- for ($index = 0; $index -le $script:FeatureFolderSegmentIndex; $index++) {
282
- if ($segment[$index].IndexOf('*') -ge 0) {
283
- return $true
284
- }
285
- }
286
-
287
- return $false
288
- }
289
-
290
240
  function Get-PathTokenKind {
291
241
  <#
292
242
  .SYNOPSIS
@@ -300,6 +250,13 @@ function Get-PathTokenKind {
300
250
  drive. Acceptance then requires one of the two documented shape rules, a
301
251
  known top-level segment or a recognized final extension.
302
252
 
253
+ A token carrying any configured placeholder or interpolation marker is
254
+ rejected wherever the marker sits, because it documents a shape rather
255
+ than naming a file (issue #502). The rejection is silent and returns the
256
+ same null value the sibling rejections return: there is no diagnostic
257
+ channel and no finding rule, because a shape citation is not an error on
258
+ the author's part and reporting one would fire on almost every plan.
259
+
303
260
  .PARAMETER Token
304
261
  A single whitespace-free inline-code token.
305
262
 
@@ -337,6 +294,22 @@ function Get-PathTokenKind {
337
294
  }
338
295
  }
339
296
 
297
+ # A token carrying a placeholder or interpolation marker documents a shape
298
+ # rather than naming a file, so it is not a write claim (issue #502).
299
+ #
300
+ # Ordering, both directions. This runs AFTER the root-surface loop because
301
+ # that loop is exact ordinal equality against a configured surface name: a
302
+ # configured surface cannot contain a marker, so the two tests can never
303
+ # disagree, and putting the cheaper marker scan first would only add work to
304
+ # the common accepted case. It runs BEFORE the separator guard because a
305
+ # marker-bearing token frequently does carry a separator and would otherwise
306
+ # sail past that guard and reach the extension rule, which accepts it: the
307
+ # dominant corpus shape is an angle-bracketed leading segment followed by a
308
+ # real .md tail, and that is exactly the token this guard exists to reject.
309
+ if (Test-PlaceholderMarker -Token $Token) {
310
+ return $null
311
+ }
312
+
340
313
  $separatorIndex = $Token.IndexOf('/')
341
314
  if ($separatorIndex -lt 0 -or $separatorIndex -eq 0) {
342
315
  return $null
@@ -492,6 +465,7 @@ Export-ModuleMember -Function `
492
465
  ConvertTo-NormalizedLine, `
493
466
  Get-PlanLineScan, `
494
467
  Get-InlineCodeToken, `
468
+ Test-PlaceholderMarker, `
495
469
  Test-MultipleFeatureFolderSpan, `
496
470
  Get-PathTokenKind, `
497
471
  Get-PathFromLine, `
@@ -0,0 +1,187 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Blast-radius token-shape predicates, ported from the Python reference.
4
+
5
+ .DESCRIPTION
6
+ Destination-runtime PowerShell port of
7
+ scripts/dev_tools/_blast_radius_token_shapes.py. Holds the pure,
8
+ context-free shape tests that reject an inline-code token before
9
+ Get-PathTokenKind can record it as a repository path. A token can look like
10
+ a path and still name no file: a placeholder or interpolation marker makes it
11
+ a command or artifact shape, and a corpus-wide documentation glob makes it a
12
+ cross-corpus claim. Neither is evidence that a work item will write anything.
13
+
14
+ Test-PlaceholderMarker is new in issue #502.
15
+ Test-MultipleFeatureFolderSpan was relocated here from
16
+ BlastRadiusExtraction.psm1 in the same change, and the two module-scoped
17
+ documentation-corpus variables it reads travelled with it because $script:
18
+ scope is per module. The relocation exists because the extraction module had
19
+ two lines of headroom against the 500-line limit when the marker guard was
20
+ added, so an in-place guard was arithmetically impossible.
21
+
22
+ The Python module remains the authoritative reference implementation. This
23
+ module is a LEAF: it imports no sibling blast-radius module, so the
24
+ extraction module can import it with no possibility of a cycle. That
25
+ constraint is why the predicate does not live in BlastRadiusNormalization.psm1,
26
+ which already imports the extraction module.
27
+
28
+ Every function is pure: no filesystem, subprocess, network, or wall-clock
29
+ access, and no input is mutated.
30
+
31
+ Parity notes for maintainers:
32
+ - The marker array is character-identical to PLACEHOLDER_MARKERS in
33
+ scripts/dev_tools/_blast_radius_token_shapes.py and to the tuple of the
34
+ same name in scripts/dev_tools/plan_gate_coverage.py, whose origin is
35
+ the checkable-literal placeholder guard recorded in
36
+ .claude/rules/plan-acceptance-gates.md. The marker set is a module
37
+ constant, not a truth-table key: it describes what a path can never
38
+ contain rather than a policy a repository could tune.
39
+ - Every marker literal is single-quoted, and the two dollar forms are
40
+ built by character concatenation. A double-quoted PowerShell string
41
+ expands the subexpression form and the delimited-variable form, so a
42
+ double-quoted marker literal would silently define a DIFFERENT
43
+ vocabulary than the Python reference and the parity would fail on
44
+ exactly the two members that matter most.
45
+ - Substring search uses [System.StringComparison]::Ordinal so the result
46
+ is culture-independent and matches Python's byte-wise 'in' operator.
47
+ - Both predicates are total on every string, including the empty string, a
48
+ token consisting only of a marker, and a bare bracket pair. Neither
49
+ throws for any input, because the classifier that calls them runs over
50
+ every inline-code span in a document and a throw would abort an entire
51
+ derivation over one stray span.
52
+ #>
53
+
54
+ Set-StrictMode -Version Latest
55
+
56
+ # Placeholder and interpolation markers. A token carrying any of these was
57
+ # written to document a shape, not to name a file, so it can never be a write
58
+ # claim.
59
+ #
60
+ # The angle brackets are the dominant corpus shape and are also the strongest
61
+ # case: Windows forbids both characters in a filename outright, so an
62
+ # angle-bracketed token cannot name a file on the platform this repository is
63
+ # developed on. The two dollar forms are shell and PowerShell interpolation, and
64
+ # the percent form is the Windows shell's environment-variable syntax; each
65
+ # resolves at run time to text that is not in the token.
66
+ #
67
+ # The two dollar forms are assembled from single characters deliberately. Written
68
+ # as one single-quoted literal they would be correct, but assembling them makes
69
+ # the intent explicit at the definition site and removes any question of what a
70
+ # future edit to the quoting style would do.
71
+ $script:PlaceholderMarker = [string[]]@(
72
+ '<',
73
+ '>',
74
+ '$' + '{',
75
+ '$' + '(',
76
+ '%'
77
+ )
78
+
79
+ # Documentation-corpus root and the index, counted after that prefix, of the
80
+ # segment that names one feature folder. A glob whose wildcard reaches this
81
+ # segment or any earlier one claims every feature folder in the corpus.
82
+ $script:FeatureCorpusPrefix = 'docs/features/'
83
+ $script:FeatureFolderSegmentIndex = 1
84
+
85
+ function Test-PlaceholderMarker {
86
+ <#
87
+ .SYNOPSIS
88
+ Report whether a token carries a placeholder or interpolation marker.
89
+
90
+ .DESCRIPTION
91
+ Port of contains_placeholder_marker. A marker-bearing token documents a
92
+ shape rather than naming a file. Two work items that cite the same
93
+ mandated artifact shape therefore acquired a path-level conflict edge on
94
+ a string that resolves to nothing, which made thematically unrelated
95
+ items contend and serialized runs that had no reason to serialize
96
+ (issue #502).
97
+
98
+ The test is a plain substring scan over a fixed vocabulary, deliberately
99
+ context-free: it needs no repository lookup, no configuration, and no
100
+ knowledge of which segment the marker sits in. A marker anywhere in the
101
+ token is disqualifying, including in the filename position, because an
102
+ interpolated filename is as unresolvable as an interpolated directory.
103
+
104
+ .PARAMETER Token
105
+ A single whitespace-free inline-code token. The empty string is accepted
106
+ and reports false.
107
+
108
+ .OUTPUTS
109
+ System.Boolean. True when any configured marker appears anywhere in the
110
+ token, otherwise false.
111
+ #>
112
+ [CmdletBinding()]
113
+ [OutputType([bool])]
114
+ param(
115
+ [Parameter(Mandatory = $true)]
116
+ [AllowEmptyString()]
117
+ [string] $Token
118
+ )
119
+
120
+ # Ordinal comparison keeps the result culture-independent and byte-wise, so
121
+ # it agrees with Python's 'in' operator on every input.
122
+ foreach ($marker in $script:PlaceholderMarker) {
123
+ if ($Token.IndexOf($marker, [System.StringComparison]::Ordinal) -ge 0) {
124
+ return $true
125
+ }
126
+ }
127
+
128
+ return $false
129
+ }
130
+
131
+ function Test-MultipleFeatureFolderSpan {
132
+ <#
133
+ .SYNOPSIS
134
+ Report whether a glob claims more than one documentation feature folder.
135
+
136
+ .DESCRIPTION
137
+ Port of spans_multiple_feature_folders. The documentation corpus is laid
138
+ out as docs/features/<bucket>/<feature-folder>/..., so a glob whose
139
+ wildcard occupies or truncates the feature-folder segment claims every
140
+ feature folder in the corpus. That made two unrelated work items contend
141
+ purely because both wrote documentation (issue #489). A glob carrying a
142
+ complete, wildcard-free feature-folder segment claims one folder and is
143
+ retained.
144
+
145
+ .PARAMETER Token
146
+ A wildcard-bearing token already accepted by the shape rules of
147
+ Get-PathTokenKind.
148
+
149
+ .OUTPUTS
150
+ System.Boolean. True when the token is rooted in the documentation corpus
151
+ and its wildcard reaches the feature-folder segment or any earlier one.
152
+ #>
153
+ [CmdletBinding()]
154
+ [OutputType([bool])]
155
+ param(
156
+ [Parameter(Mandatory = $true)]
157
+ [AllowEmptyString()]
158
+ [string] $Token
159
+ )
160
+
161
+ if (-not $Token.StartsWith($script:FeatureCorpusPrefix,
162
+ [System.StringComparison]::Ordinal)) {
163
+ return $false
164
+ }
165
+
166
+ $segment = @($Token.Substring($script:FeatureCorpusPrefix.Length) -split '/')
167
+
168
+ # A token that stops at or before the feature-folder segment has had that
169
+ # segment truncated away by the wildcard, so it spans the whole corpus.
170
+ if ($segment.Count -le $script:FeatureFolderSegmentIndex) {
171
+ return $true
172
+ }
173
+
174
+ # Every segment up to and including the feature-folder name must be a literal
175
+ # for the claim to resolve to exactly one folder.
176
+ for ($index = 0; $index -le $script:FeatureFolderSegmentIndex; $index++) {
177
+ if ($segment[$index].IndexOf('*') -ge 0) {
178
+ return $true
179
+ }
180
+ }
181
+
182
+ return $false
183
+ }
184
+
185
+ Export-ModuleMember -Function `
186
+ Test-PlaceholderMarker, `
187
+ Test-MultipleFeatureFolderSpan