@danmoisan/drm-copilot-mcp 1.0.26 → 1.1.0

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 (63) hide show
  1. package/out/mcp-server.js +542 -42
  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 +172 -24
  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 +402 -0
  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.ps1 +59 -14
  17. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +17 -20
  18. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier-helpers.ps1 +278 -0
  19. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +55 -271
  20. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +57 -22
  21. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +56 -19
  22. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +70 -27
  23. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +228 -0
  24. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +68 -225
  25. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +161 -34
  26. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +59 -22
  27. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +70 -27
  28. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +32 -18
  29. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +105 -2
  30. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +32 -52
  31. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +107 -99
  32. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 +295 -0
  33. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +9 -3
  34. package/resources/claude-customizations/.claude/lib/hook-payload/HookPayload.psm1 +494 -0
  35. package/resources/claude-customizations/.claude/lib/mermaid/MermaidGrammar.psm1 +491 -0
  36. package/resources/claude-customizations/.claude/lib/mermaid/MermaidLineScanner.psm1 +488 -0
  37. package/resources/claude-customizations/.claude/lib/mermaid/MermaidMarkdownFences.psm1 +298 -0
  38. package/resources/claude-customizations/.claude/lib/mermaid/MermaidValidation.psm1 +496 -0
  39. package/resources/claude-customizations/.claude/rules/mermaid.md +142 -0
  40. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +124 -1
  41. package/resources/claude-customizations/.claude/rules/plan-acceptance-gates.md +116 -0
  42. package/resources/claude-customizations/.claude/settings.json +5 -0
  43. package/resources/claude-customizations/.claude/skills/atomic-plan-contract/SKILL.md +15 -0
  44. package/resources/claude-customizations/.claude/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  45. package/resources/claude-customizations/.claude/skills/feature-promotion-lifecycle/SKILL.md +6 -0
  46. package/resources/claude-customizations/.claude/skills/mermaid-diagram/SKILL.md +184 -0
  47. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/c4.md +50 -0
  48. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/class.md +63 -0
  49. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/er.md +56 -0
  50. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/flowchart.md +68 -0
  51. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/gantt.md +51 -0
  52. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/other-types.md +82 -0
  53. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/pie.md +32 -0
  54. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/sequence.md +63 -0
  55. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/state.md +49 -0
  56. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +8 -7
  57. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +24 -4
  58. package/resources/claude-customizations/config/blast-radius.json +16 -2
  59. package/resources/claude-customizations/pack-manifests/core.json +22 -1
  60. package/resources/codex-and-agents-customizations/.agents/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  61. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  62. package/resources/customizations/.github/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  63. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +31 -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') {
@@ -54,6 +54,7 @@ Set-StrictMode -Version Latest
54
54
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force
55
55
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force
56
56
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force
57
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force
57
58
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusValidation.psm1') -Force
58
59
 
59
60
  # Feature-folder handling. Every radius contains its own feature folder, and a
@@ -66,6 +67,11 @@ $script:FeatureFolderPrefix = 'docs/features/'
66
67
  $script:SourceDerived = 'derived'
67
68
  $script:SourceObserved = 'observed'
68
69
 
70
+ # A contract identifier names something callable or referenceable, so it must
71
+ # carry at least one ASCII letter (issue #489). Get-NormalizedDeclaredRadius
72
+ # re-applies that rule to identifiers a pre-#489 extractor already recorded.
73
+ $script:ContractLetterPattern = [regex]::new('[A-Za-z]')
74
+
69
75
  # Contention reason kinds, in the fixed order every result reports them. These
70
76
  # strings are contract literals consumed by the downstream parallel schema.
71
77
  $script:ConflictPathOverlap = 'path_overlap'
@@ -167,10 +173,20 @@ function Get-BlastRadius {
167
173
  $entry = [System.Collections.Generic.List[string]]::new()
168
174
  $entry.AddRange([string[]]@(Get-PlanPaths -PlanText $PlanText -RootSurface $rootSurface))
169
175
  $entry.AddRange([string[]]@(Get-PathFromLine -Line $specLine -RootSurface $rootSurface))
170
- $entry.Add((Get-FeatureFolderGlob -FeatureFolder (
176
+
177
+ # Read-by-mandate citations are dropped before the feature folder is added: a
178
+ # plan cites the policy rules because its author was told to read them, not
179
+ # because the change will write them (issue #489). Test-BlastRadius applies
180
+ # the same filter, which is what keeps the derived radius passing V1 and V2
181
+ # against its own plan. The feature-folder glob is added afterwards so it can
182
+ # never be excluded.
183
+ $surviving = [System.Collections.Generic.List[string]]::new()
184
+ $surviving.AddRange([string[]]@(Get-NonMandateReadEntry -Entry $entry.ToArray() `
185
+ -MandateRead ([string[]]@(Get-ConfigMandateRead -Config $Config))))
186
+ $surviving.Add((Get-FeatureFolderGlob -FeatureFolder (
171
187
  Get-RequiredText -Value $FeatureFolder -FieldName 'feature_folder')))
172
188
 
173
- $paths = [string[]]@(Get-OrdinalSortedEntry -Entry $entry.ToArray())
189
+ $paths = [string[]]@(Get-OrdinalSortedEntry -Entry $surviving.ToArray())
174
190
  $concrete = [string[]]@(Get-ConcreteEntry -Entry $paths)
175
191
 
176
192
  return ConvertTo-NormalizedBlastRadius -Radius @{
@@ -183,6 +199,92 @@ function Get-BlastRadius {
183
199
  }
184
200
  }
185
201
 
202
+ function Get-NormalizedDeclaredRadius {
203
+ <#
204
+ .SYNOPSIS
205
+ Re-apply the current extraction rules to an already-recorded radius.
206
+
207
+ .DESCRIPTION
208
+ Port of normalize_declared_radius. A radius recorded by an older
209
+ extractor can carry entries the current rules reject: directory-shaped
210
+ tokens, cross-corpus documentation globs, read-by-mandate citations, and
211
+ letterless contract tokens. Re-deriving from the plan text is not always
212
+ possible, so this function re-filters the recorded radius in place of a
213
+ fresh derivation and re-resolves the levels that depend on the surviving
214
+ paths (issue #489). source and computed_at are preserved and the input is
215
+ never mutated.
216
+
217
+ .PARAMETER Radius
218
+ A derived or declared radius hashtable to re-filter.
219
+
220
+ .PARAMETER Config
221
+ Parsed config/blast-radius.json. The root-surface set, the mandate-read
222
+ list, the module map, and the shared-surface list are all read from this
223
+ one value.
224
+
225
+ .OUTPUTS
226
+ System.Collections.Hashtable. A new radius carrying the surviving paths,
227
+ contracts re-filtered by the ASCII-letter rule, and modules and shared
228
+ surfaces re-resolved from the surviving paths. Throws when the radius
229
+ source is observed: an observed radius records a diff listing rather than
230
+ a plan-text harvest, so the plan-text acceptance rules do not apply to it
231
+ and re-filtering one would silently discard genuine evidence.
232
+ #>
233
+ [CmdletBinding()]
234
+ [OutputType([hashtable])]
235
+ param(
236
+ [Parameter(Mandatory = $true)]
237
+ [AllowNull()]
238
+ [object] $Radius,
239
+ [Parameter(Mandatory = $true)]
240
+ [AllowNull()]
241
+ [object] $Config
242
+ )
243
+
244
+ $normalized = ConvertTo-NormalizedBlastRadius -Radius $Radius
245
+
246
+ # Fail fast, before any other work, so the prohibition is unambiguous and no
247
+ # partially-filtered value can escape.
248
+ if ($normalized['source'] -eq $script:SourceObserved) {
249
+ throw ("Get-NormalizedDeclaredRadius rejects a radius whose source is " +
250
+ "'$($script:SourceObserved)': an observed radius records a diff listing, " +
251
+ 'not a plan-text harvest, so the plan-text acceptance rules must not ' +
252
+ 'be applied to it.')
253
+ }
254
+
255
+ $rootSurface = [string[]]@(Get-ConfigRootSurface -Config $Config)
256
+
257
+ # Re-run the classifier over each recorded entry. An entry the current rules
258
+ # reject is dropped; the mandate-read filter then removes the citations that
259
+ # are evidence of a read rather than of a write.
260
+ $accepted = [System.Collections.Generic.List[string]]::new()
261
+ foreach ($entry in @($normalized['paths'])) {
262
+ if ($null -ne (Get-PathTokenKind -Token $entry -RootSurface $rootSurface)) {
263
+ $accepted.Add([string]$entry)
264
+ }
265
+ }
266
+
267
+ $paths = [string[]]@(Get-NonMandateReadEntry -Entry $accepted.ToArray() `
268
+ -MandateRead ([string[]]@(Get-ConfigMandateRead -Config $Config)))
269
+ $concrete = [string[]]@(Get-ConcreteEntry -Entry $paths)
270
+
271
+ $contract = [System.Collections.Generic.List[string]]::new()
272
+ foreach ($identifier in @($normalized['contracts'])) {
273
+ if ($script:ContractLetterPattern.IsMatch([string]$identifier)) {
274
+ $contract.Add([string]$identifier)
275
+ }
276
+ }
277
+
278
+ return ConvertTo-NormalizedBlastRadius -Radius @{
279
+ paths = $paths
280
+ modules = @(Resolve-BlastRadiusModule -PathEntry $paths -Config $Config)
281
+ shared_surfaces = @(Resolve-BlastRadiusSharedSurface -ConcretePath $concrete -Config $Config)
282
+ contracts = @($contract.ToArray())
283
+ source = $normalized['source']
284
+ computed_at = $normalized['computed_at']
285
+ }
286
+ }
287
+
186
288
  function Get-BlastRadiusFromObservedPaths {
187
289
  <#
188
290
  .SYNOPSIS
@@ -374,6 +476,7 @@ function Test-BlastRadiusConflict {
374
476
  Export-ModuleMember -Function `
375
477
  Get-PlanPaths, `
376
478
  Get-BlastRadius, `
479
+ Get-NormalizedDeclaredRadius, `
377
480
  Get-BlastRadiusFromObservedPaths, `
378
481
  Test-BlastRadius, `
379
482
  Test-BlastRadiusConflict
@@ -39,6 +39,7 @@ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1')
39
39
  $script:ConfigSharedSurfaceKey = 'shared_surfaces'
40
40
  $script:ConfigSharedSurfaceGlobKey = 'shared_surface_globs'
41
41
  $script:ConfigModuleKey = 'modules'
42
+ $script:ConfigMandateReadKey = 'mandate_reads'
42
43
  $script:ConfigOverBreadthKey = 'over_breadth_fraction'
43
44
 
44
45
  # Numeric types a JSON or literal truth table may carry for the V3 threshold.
@@ -279,6 +280,36 @@ function Get-ConfigRootSurface {
279
280
  return @($rootSurface.ToArray())
280
281
  }
281
282
 
283
+ function Get-ConfigMandateRead {
284
+ <#
285
+ .SYNOPSIS
286
+ Read the read-by-mandate exclusion list from the truth table.
287
+
288
+ .DESCRIPTION
289
+ Port of config_mandate_reads. Mandate reads are the paths every agent is
290
+ instructed to read before doing any work, so a citation of one of them is
291
+ evidence that the author obeyed the reading order rather than evidence
292
+ that the change will write the file (issue #489).
293
+
294
+ .PARAMETER Config
295
+ Parsed config/blast-radius.json. Only the mandate_reads key is read.
296
+
297
+ .OUTPUTS
298
+ System.Object[]. Entries sorted and deduplicated by the underlying
299
+ reader. A config with no mandate_reads key yields an empty array, which
300
+ excludes nothing and reproduces pre-change behavior.
301
+ #>
302
+ [CmdletBinding()]
303
+ [OutputType([System.Object[]])]
304
+ param(
305
+ [Parameter(Mandatory = $true)]
306
+ [AllowNull()]
307
+ [object] $Config
308
+ )
309
+
310
+ return @(Get-ConfigStringList -Config $Config -Key $script:ConfigMandateReadKey)
311
+ }
312
+
282
313
  function Get-ConfigModuleEntry {
283
314
  <#
284
315
  .SYNOPSIS
@@ -374,57 +405,6 @@ function Get-ConfigOverBreadthFraction {
374
405
  return $fraction
375
406
  }
376
407
 
377
- function Resolve-BlastRadiusModule {
378
- <#
379
- .SYNOPSIS
380
- Resolve path entries to the module names of the truth-table map.
381
-
382
- .DESCRIPTION
383
- Port of resolve_modules. A module joins the radius as soon as one of its
384
- globs covers one entry, so the search stops at the first hit per module.
385
- A path matching no glob resolves to no module.
386
-
387
- .PARAMETER PathEntry
388
- Concrete paths and globs of a radius. An empty collection is accepted.
389
-
390
- .PARAMETER Config
391
- Parsed config/blast-radius.json.
392
-
393
- .OUTPUTS
394
- System.Object[]. Matched module names, deduplicated and ordinally sorted.
395
- #>
396
- [CmdletBinding()]
397
- [OutputType([System.Object[]])]
398
- param(
399
- [Parameter(Mandatory = $true)]
400
- [AllowEmptyCollection()]
401
- [AllowEmptyString()]
402
- [string[]] $PathEntry,
403
- [Parameter(Mandatory = $true)]
404
- [AllowNull()]
405
- [object] $Config
406
- )
407
-
408
- $matched = [System.Collections.Generic.List[string]]::new()
409
- foreach ($pair in @(Get-ConfigModuleEntry -Config $Config)) {
410
- foreach ($pattern in $pair['globs']) {
411
- $hit = $false
412
- foreach ($entry in $PathEntry) {
413
- if (Test-GlobMatch -Pattern $pattern -Candidate $entry) {
414
- $matched.Add([string]$pair['name'])
415
- $hit = $true
416
- break
417
- }
418
- }
419
- if ($hit) {
420
- break
421
- }
422
- }
423
- }
424
-
425
- return @(Get-OrdinalSortedEntry -Entry $matched.ToArray())
426
- }
427
-
428
408
  function Resolve-BlastRadiusSharedSurface {
429
409
  <#
430
410
  .SYNOPSIS
@@ -485,7 +465,7 @@ Export-ModuleMember -Function `
485
465
  Get-RequiredMapping, `
486
466
  Get-ConfigStringList, `
487
467
  Get-ConfigRootSurface, `
468
+ Get-ConfigMandateRead, `
488
469
  Get-ConfigModuleEntry, `
489
470
  Get-ConfigOverBreadthFraction, `
490
- Resolve-BlastRadiusModule, `
491
471
  Resolve-BlastRadiusSharedSurface