@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.
- package/out/mcp-server.js +0 -1
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/hooks/check-powershell-test-purity.ps1 +17 -21
- package/resources/claude-customizations/.claude/hooks/check-python-test-purity.ps1 +17 -19
- package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +17 -19
- package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +18 -19
- package/resources/claude-customizations/.claude/hooks/enforce-discovery-artifact-gate.ps1 +18 -19
- package/resources/claude-customizations/.claude/hooks/enforce-epic-invocation-origin.ps1 +54 -32
- package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +64 -20
- package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +51 -22
- package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +56 -19
- package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +71 -28
- package/resources/claude-customizations/.claude/hooks/enforce-feature-folder-order.ps1 +68 -23
- package/resources/claude-customizations/.claude/hooks/enforce-mermaid-validation.ps1 +36 -24
- package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +20 -22
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +349 -0
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +177 -20
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +17 -20
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier-helpers.ps1 +278 -0
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +55 -271
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +57 -22
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +56 -19
- package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +70 -27
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +228 -0
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +68 -225
- package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +161 -34
- package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +59 -22
- package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +70 -27
- package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +32 -18
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +34 -60
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusTokenShape.psm1 +187 -0
- package/resources/claude-customizations/.claude/lib/hook-payload/HookPayload.psm1 +494 -0
- package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +125 -4
- package/resources/claude-customizations/.claude/skills/epic-plan/SKILL.md +27 -0
- package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +28 -0
- package/resources/claude-customizations/config/blast-radius.json +9 -3
- package/resources/claude-customizations/pack-manifests/core.json +5 -0
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +349 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +123 -6
- package/resources/codex-and-agents-customizations/pack-manifests/core.json +1 -0
- 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
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 = $
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
$
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 = $
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
$
|
|
219
|
-
|
|
220
|
-
$sessionId = 'default'
|
|
221
|
-
}
|
|
238
|
+
[scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
|
|
239
|
+
)
|
|
222
240
|
|
|
223
|
-
$
|
|
224
|
-
$
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
267
|
+
|
|
268
|
+
# Guard allows dot-sourcing in tests without executing the entrypoint.
|
|
269
|
+
if ($MyInvocation.InvocationName -eq '.') {
|
|
270
|
+
return
|
|
230
271
|
}
|
|
231
272
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 =
|
|
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
|