@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
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Shared PreToolUse hook payload reader: transport, envelope parse, and strict
|
|
4
|
+
nested tool_input extraction (issue #501).
|
|
5
|
+
|
|
6
|
+
.DESCRIPTION
|
|
7
|
+
Every PreToolUse hook under .claude/hooks/ acquires its payload through this
|
|
8
|
+
module instead of re-implementing the parse inline. The module owns three
|
|
9
|
+
concerns.
|
|
10
|
+
|
|
11
|
+
Transport. Claude Code delivers command-hook input on stdin as JSON.
|
|
12
|
+
Read-ClaudeHookRawPayload reads stdin first, then falls back to the
|
|
13
|
+
CLAUDE_HOOK_INPUT environment variable and then the CLAUDE_TOOL_INPUT
|
|
14
|
+
environment variable; the first non-whitespace source wins. Both stdin
|
|
15
|
+
operations sit behind injectable scriptblock seams so tests drive them without
|
|
16
|
+
touching a .NET static and without spawning a process.
|
|
17
|
+
|
|
18
|
+
Shape. Tool arguments are nested under the envelope's tool_input object.
|
|
19
|
+
Get-ClaudeHookToolInput extracts that object strictly: a parsed payload with no
|
|
20
|
+
tool_input key, or a tool_input that is null or not an object, is an envelope
|
|
21
|
+
anomaly. There is deliberately no flat-root fallback, because a permissive
|
|
22
|
+
dual-shape reader would keep every legacy-shaped fixture green and restore the
|
|
23
|
+
silent-allow failure mode on the next contract drift.
|
|
24
|
+
|
|
25
|
+
Anomaly classification. The parse and extract functions never return a silent
|
|
26
|
+
null for malformed input; they return a typed result whose Anomaly property
|
|
27
|
+
names the failure, and Get-ClaudeHookPayloadAnomalyReason maps that code to the
|
|
28
|
+
deny reason text a hook emits.
|
|
29
|
+
|
|
30
|
+
Fail-closed posture: an envelope-level anomaly is a deny, emitted as decision
|
|
31
|
+
JSON at process exit code 0. Exit code 1 is non-blocking for PreToolUse, so a
|
|
32
|
+
throwing hook is itself a fail-open. Property-level absence inside a well-formed
|
|
33
|
+
tool_input is NOT an anomaly: that is each hook's own scope filter and keeps its
|
|
34
|
+
existing allow early-return.
|
|
35
|
+
|
|
36
|
+
.NOTES
|
|
37
|
+
Compatible with PowerShell 7+. No external module dependencies, no filesystem
|
|
38
|
+
access, no subprocess, no network, and no wall-clock read. Mirrored
|
|
39
|
+
byte-identically under extensions/drm-copilot/resources/claude-customizations/.
|
|
40
|
+
#>
|
|
41
|
+
|
|
42
|
+
Set-StrictMode -Version Latest
|
|
43
|
+
|
|
44
|
+
# Anomaly codes. These are the only values Get-ClaudeHookPayloadAnomalyReason maps.
|
|
45
|
+
$script:AnomalyEmptyPayload = 'EmptyPayload'
|
|
46
|
+
$script:AnomalyUnparseableJson = 'UnparseableJson'
|
|
47
|
+
$script:AnomalyMissingToolInput = 'MissingToolInput'
|
|
48
|
+
$script:AnomalyNullToolInput = 'NullToolInput'
|
|
49
|
+
$script:AnomalyNonObjectToolInput = 'NonObjectToolInput'
|
|
50
|
+
|
|
51
|
+
# The nested key that carries the tool arguments in the documented envelope.
|
|
52
|
+
$script:ToolInputKey = 'tool_input'
|
|
53
|
+
|
|
54
|
+
# Deny reason text per anomaly code. Each string is distinct so an operator can
|
|
55
|
+
# tell which leg of the contract drifted from the emitted decision alone.
|
|
56
|
+
$script:AnomalyReasonText = @{
|
|
57
|
+
'EmptyPayload' = 'the hook received an empty payload on stdin and on both environment-variable fallbacks'
|
|
58
|
+
'UnparseableJson' = 'the hook received a payload that is not parseable JSON'
|
|
59
|
+
'MissingToolInput' = 'the hook received a JSON payload with no tool_input key (the legacy flat root shape is an envelope anomaly, not a supported payload)'
|
|
60
|
+
'NullToolInput' = 'the hook received a JSON payload whose tool_input is null'
|
|
61
|
+
'NonObjectToolInput' = 'the hook received a JSON payload whose tool_input is not an object'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function Get-ClaudeHookPayloadAnomalyCode {
|
|
65
|
+
<#
|
|
66
|
+
.SYNOPSIS
|
|
67
|
+
Return the anomaly codes this module can emit, so callers need not
|
|
68
|
+
hard-code the literals in a second place.
|
|
69
|
+
.OUTPUTS
|
|
70
|
+
System.String[]
|
|
71
|
+
#>
|
|
72
|
+
[CmdletBinding()]
|
|
73
|
+
[OutputType([string[]])]
|
|
74
|
+
param()
|
|
75
|
+
|
|
76
|
+
return [string[]]@(
|
|
77
|
+
$script:AnomalyEmptyPayload,
|
|
78
|
+
$script:AnomalyUnparseableJson,
|
|
79
|
+
$script:AnomalyMissingToolInput,
|
|
80
|
+
$script:AnomalyNullToolInput,
|
|
81
|
+
$script:AnomalyNonObjectToolInput
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function Get-ClaudeHookPayloadAnomalyReason {
|
|
86
|
+
<#
|
|
87
|
+
.SYNOPSIS
|
|
88
|
+
Map an anomaly code to the clause a hook puts in its deny reason.
|
|
89
|
+
.PARAMETER Anomaly
|
|
90
|
+
One of the codes returned by Get-ClaudeHookPayloadAnomalyCode.
|
|
91
|
+
.OUTPUTS
|
|
92
|
+
System.String
|
|
93
|
+
#>
|
|
94
|
+
[CmdletBinding()]
|
|
95
|
+
[OutputType([string])]
|
|
96
|
+
param(
|
|
97
|
+
[AllowNull()]
|
|
98
|
+
[AllowEmptyString()]
|
|
99
|
+
[string] $Anomaly
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if ([string]::IsNullOrWhiteSpace($Anomaly)) {
|
|
103
|
+
return 'the hook received an unclassified payload anomaly'
|
|
104
|
+
}
|
|
105
|
+
if ($script:AnomalyReasonText.ContainsKey($Anomaly)) {
|
|
106
|
+
return [string]$script:AnomalyReasonText[$Anomaly]
|
|
107
|
+
}
|
|
108
|
+
return ('the hook received an unrecognized payload anomaly ({0})' -f $Anomaly)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ConvertTo-ClaudeHookPayloadResult {
|
|
112
|
+
<#
|
|
113
|
+
.SYNOPSIS
|
|
114
|
+
Build the typed result every parse/extract function returns: IsValid, Value
|
|
115
|
+
(the parsed envelope or extracted tool_input), and Anomaly (null when
|
|
116
|
+
valid). Always an object, so no caller tests for null first.
|
|
117
|
+
.OUTPUTS
|
|
118
|
+
System.Management.Automation.PSCustomObject
|
|
119
|
+
#>
|
|
120
|
+
[CmdletBinding()]
|
|
121
|
+
[OutputType([pscustomobject])]
|
|
122
|
+
param(
|
|
123
|
+
[Parameter(Mandatory)]
|
|
124
|
+
[bool] $IsValid,
|
|
125
|
+
|
|
126
|
+
[AllowNull()]
|
|
127
|
+
[object] $Value,
|
|
128
|
+
|
|
129
|
+
[AllowNull()]
|
|
130
|
+
[AllowEmptyString()]
|
|
131
|
+
[string] $Anomaly
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
$normalizedAnomaly = $null
|
|
135
|
+
if (-not [string]::IsNullOrWhiteSpace($Anomaly)) {
|
|
136
|
+
$normalizedAnomaly = [string]$Anomaly
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return [pscustomobject]@{
|
|
140
|
+
IsValid = $IsValid
|
|
141
|
+
Value = $Value
|
|
142
|
+
Anomaly = $normalizedAnomaly
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function Read-ClaudeHookRawPayload {
|
|
147
|
+
<#
|
|
148
|
+
.SYNOPSIS
|
|
149
|
+
Acquire the raw hook payload text, stdin first.
|
|
150
|
+
|
|
151
|
+
.DESCRIPTION
|
|
152
|
+
Ordering: stdin, then the CLAUDE_HOOK_INPUT environment variable, then the
|
|
153
|
+
CLAUDE_TOOL_INPUT environment variable. The first non-whitespace source
|
|
154
|
+
wins; when every source is empty or whitespace the function returns an empty
|
|
155
|
+
string, which callers classify as the EmptyPayload anomaly.
|
|
156
|
+
|
|
157
|
+
The stdin read is guarded by a redirect probe. An unguarded
|
|
158
|
+
[Console]::In.ReadToEnd() blocks indefinitely on a non-redirected console,
|
|
159
|
+
which would hang the documented manual/CLI invocation of validate-bash.ps1.
|
|
160
|
+
The guard lives in this function body rather than inside the read seam's
|
|
161
|
+
default so a test can drive both polarities by injection. A stdin read that
|
|
162
|
+
throws falls back rather than propagating (persist-session-id.ps1 precedent).
|
|
163
|
+
|
|
164
|
+
.PARAMETER ReadStandardInput
|
|
165
|
+
Seam for the stdin read. Default is the bare [Console]::In.ReadToEnd().
|
|
166
|
+
.PARAMETER TestStandardInputRedirected
|
|
167
|
+
Seam for the redirect probe. Default is [Console]::IsInputRedirected. When
|
|
168
|
+
it evaluates falsey, stdin is treated as empty and the env fallback runs.
|
|
169
|
+
.PARAMETER HookInputFallback
|
|
170
|
+
Seam for the first environment fallback.
|
|
171
|
+
.PARAMETER ToolInputFallback
|
|
172
|
+
Seam for the second environment fallback.
|
|
173
|
+
.OUTPUTS
|
|
174
|
+
System.String
|
|
175
|
+
#>
|
|
176
|
+
[CmdletBinding()]
|
|
177
|
+
[OutputType([string])]
|
|
178
|
+
param(
|
|
179
|
+
[scriptblock] $ReadStandardInput = { [Console]::In.ReadToEnd() },
|
|
180
|
+
|
|
181
|
+
[scriptblock] $TestStandardInputRedirected = { [Console]::IsInputRedirected },
|
|
182
|
+
|
|
183
|
+
[AllowNull()]
|
|
184
|
+
[AllowEmptyString()]
|
|
185
|
+
[string] $HookInputFallback = $env:CLAUDE_HOOK_INPUT,
|
|
186
|
+
|
|
187
|
+
[AllowNull()]
|
|
188
|
+
[AllowEmptyString()]
|
|
189
|
+
[string] $ToolInputFallback = $env:CLAUDE_TOOL_INPUT
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
$raw = ''
|
|
193
|
+
|
|
194
|
+
$isRedirected = $false
|
|
195
|
+
try {
|
|
196
|
+
$isRedirected = [bool](& $TestStandardInputRedirected)
|
|
197
|
+
} catch {
|
|
198
|
+
$isRedirected = $false
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if ($isRedirected) {
|
|
202
|
+
try {
|
|
203
|
+
$raw = [string](& $ReadStandardInput)
|
|
204
|
+
} catch {
|
|
205
|
+
$raw = ''
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (-not [string]::IsNullOrWhiteSpace($raw)) {
|
|
210
|
+
return $raw
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (-not [string]::IsNullOrWhiteSpace($HookInputFallback)) {
|
|
214
|
+
return [string]$HookInputFallback
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (-not [string]::IsNullOrWhiteSpace($ToolInputFallback)) {
|
|
218
|
+
return [string]$ToolInputFallback
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return ''
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function ConvertFrom-ClaudeHookEnvelope {
|
|
225
|
+
<#
|
|
226
|
+
.SYNOPSIS
|
|
227
|
+
Parse raw hook payload text into the envelope object.
|
|
228
|
+
|
|
229
|
+
.DESCRIPTION
|
|
230
|
+
Strips a UTF-8 byte-order mark and normalizes CRLF before parsing, because
|
|
231
|
+
stdin text arrives with either line ending and a BOM survives a piped read.
|
|
232
|
+
Never returns a silent null for malformed input: an empty or whitespace-only
|
|
233
|
+
payload yields the EmptyPayload anomaly and unparseable text yields the
|
|
234
|
+
UnparseableJson anomaly.
|
|
235
|
+
|
|
236
|
+
.PARAMETER Raw
|
|
237
|
+
The raw payload text from Read-ClaudeHookRawPayload.
|
|
238
|
+
.OUTPUTS
|
|
239
|
+
System.Management.Automation.PSCustomObject (typed result)
|
|
240
|
+
#>
|
|
241
|
+
[CmdletBinding()]
|
|
242
|
+
[OutputType([pscustomobject])]
|
|
243
|
+
param(
|
|
244
|
+
[AllowNull()]
|
|
245
|
+
[AllowEmptyString()]
|
|
246
|
+
[string] $Raw
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if ([string]::IsNullOrWhiteSpace($Raw)) {
|
|
250
|
+
return ConvertTo-ClaudeHookPayloadResult -IsValid $false -Value $null -Anomaly $script:AnomalyEmptyPayload
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
# A BOM decoded into the string appears as U+FEFF and makes ConvertFrom-Json fail.
|
|
254
|
+
$text = ([string]$Raw).TrimStart([char]0xFEFF)
|
|
255
|
+
$text = $text.Replace("`r`n", "`n")
|
|
256
|
+
|
|
257
|
+
if ([string]::IsNullOrWhiteSpace($text)) {
|
|
258
|
+
return ConvertTo-ClaudeHookPayloadResult -IsValid $false -Value $null -Anomaly $script:AnomalyEmptyPayload
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
$envelope = $text | ConvertFrom-Json -ErrorAction Stop
|
|
263
|
+
} catch {
|
|
264
|
+
return ConvertTo-ClaudeHookPayloadResult -IsValid $false -Value $null -Anomaly $script:AnomalyUnparseableJson
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if ($null -eq $envelope) {
|
|
268
|
+
return ConvertTo-ClaudeHookPayloadResult -IsValid $false -Value $null -Anomaly $script:AnomalyUnparseableJson
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return ConvertTo-ClaudeHookPayloadResult -IsValid $true -Value $envelope -Anomaly $null
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function Test-ClaudeHookEnvelopeHasKey {
|
|
275
|
+
<#
|
|
276
|
+
.SYNOPSIS
|
|
277
|
+
Report whether a parsed JSON object (PSCustomObject or dictionary) carries a
|
|
278
|
+
named key, without throwing under StrictMode.
|
|
279
|
+
.OUTPUTS
|
|
280
|
+
System.Boolean
|
|
281
|
+
#>
|
|
282
|
+
[CmdletBinding()]
|
|
283
|
+
[OutputType([bool])]
|
|
284
|
+
param(
|
|
285
|
+
[AllowNull()]
|
|
286
|
+
[object] $Envelope,
|
|
287
|
+
|
|
288
|
+
[Parameter(Mandatory)]
|
|
289
|
+
[string] $Name
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if ($null -eq $Envelope) {
|
|
293
|
+
return $false
|
|
294
|
+
}
|
|
295
|
+
if ($Envelope -is [System.Collections.IDictionary]) {
|
|
296
|
+
return $Envelope.Contains($Name)
|
|
297
|
+
}
|
|
298
|
+
if ($Envelope -isnot [psobject]) {
|
|
299
|
+
return $false
|
|
300
|
+
}
|
|
301
|
+
return (@($Envelope.PSObject.Properties.Name) -contains $Name)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function Get-ClaudeHookEnvelopeValue {
|
|
305
|
+
<#
|
|
306
|
+
.SYNOPSIS
|
|
307
|
+
Read a named value off a parsed JSON object, returning null when the key is
|
|
308
|
+
absent rather than throwing under StrictMode.
|
|
309
|
+
.OUTPUTS
|
|
310
|
+
System.Object or null
|
|
311
|
+
#>
|
|
312
|
+
[CmdletBinding()]
|
|
313
|
+
param(
|
|
314
|
+
[AllowNull()]
|
|
315
|
+
[object] $Envelope,
|
|
316
|
+
|
|
317
|
+
[Parameter(Mandatory)]
|
|
318
|
+
[string] $Name
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
if (-not (Test-ClaudeHookEnvelopeHasKey -Envelope $Envelope -Name $Name)) {
|
|
322
|
+
return $null
|
|
323
|
+
}
|
|
324
|
+
if ($Envelope -is [System.Collections.IDictionary]) {
|
|
325
|
+
return $Envelope[$Name]
|
|
326
|
+
}
|
|
327
|
+
return $Envelope.PSObject.Properties[$Name].Value
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function Test-ClaudeHookObjectValue {
|
|
331
|
+
<#
|
|
332
|
+
.SYNOPSIS
|
|
333
|
+
Report whether a parsed JSON value is an object rather than a scalar or an
|
|
334
|
+
array. ConvertFrom-Json produces a PSCustomObject for a JSON object, a
|
|
335
|
+
native scalar for a scalar, and an array for a JSON array; only the first is
|
|
336
|
+
a usable tool_input.
|
|
337
|
+
.OUTPUTS
|
|
338
|
+
System.Boolean
|
|
339
|
+
#>
|
|
340
|
+
[CmdletBinding()]
|
|
341
|
+
[OutputType([bool])]
|
|
342
|
+
param(
|
|
343
|
+
[AllowNull()]
|
|
344
|
+
[object] $Value
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if ($null -eq $Value) {
|
|
348
|
+
return $false
|
|
349
|
+
}
|
|
350
|
+
if ($Value -is [System.Collections.IDictionary]) {
|
|
351
|
+
return $true
|
|
352
|
+
}
|
|
353
|
+
if ($Value -is [string] -or $Value -is [bool] -or $Value -is [ValueType]) {
|
|
354
|
+
return $false
|
|
355
|
+
}
|
|
356
|
+
if ($Value -is [System.Collections.IEnumerable]) {
|
|
357
|
+
return $false
|
|
358
|
+
}
|
|
359
|
+
return ($Value -is [psobject])
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function Get-ClaudeHookToolInput {
|
|
363
|
+
<#
|
|
364
|
+
.SYNOPSIS
|
|
365
|
+
Extract the envelope's nested tool_input object. Strict: no flat-root
|
|
366
|
+
fallback.
|
|
367
|
+
|
|
368
|
+
.DESCRIPTION
|
|
369
|
+
A parsed payload with no tool_input key is the MissingToolInput anomaly,
|
|
370
|
+
which is exactly what the legacy flat root shape produces. A tool_input
|
|
371
|
+
present but null is NullToolInput; present but not an object is
|
|
372
|
+
NonObjectToolInput. Absence of a property inside a well-formed tool_input is
|
|
373
|
+
not handled here at all: that stays each hook's own scope filter.
|
|
374
|
+
|
|
375
|
+
.PARAMETER Envelope
|
|
376
|
+
The parsed envelope object from ConvertFrom-ClaudeHookEnvelope.
|
|
377
|
+
.OUTPUTS
|
|
378
|
+
System.Management.Automation.PSCustomObject (typed result)
|
|
379
|
+
#>
|
|
380
|
+
[CmdletBinding()]
|
|
381
|
+
[OutputType([pscustomobject])]
|
|
382
|
+
param(
|
|
383
|
+
[AllowNull()]
|
|
384
|
+
[object] $Envelope
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if ($null -eq $Envelope) {
|
|
388
|
+
return ConvertTo-ClaudeHookPayloadResult -IsValid $false -Value $null -Anomaly $script:AnomalyMissingToolInput
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (-not (Test-ClaudeHookEnvelopeHasKey -Envelope $Envelope -Name $script:ToolInputKey)) {
|
|
392
|
+
return ConvertTo-ClaudeHookPayloadResult -IsValid $false -Value $null -Anomaly $script:AnomalyMissingToolInput
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
$toolInput = Get-ClaudeHookEnvelopeValue -Envelope $Envelope -Name $script:ToolInputKey
|
|
396
|
+
|
|
397
|
+
if ($null -eq $toolInput) {
|
|
398
|
+
return ConvertTo-ClaudeHookPayloadResult -IsValid $false -Value $null -Anomaly $script:AnomalyNullToolInput
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (-not (Test-ClaudeHookObjectValue -Value $toolInput)) {
|
|
402
|
+
return ConvertTo-ClaudeHookPayloadResult -IsValid $false -Value $null -Anomaly $script:AnomalyNonObjectToolInput
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return ConvertTo-ClaudeHookPayloadResult -IsValid $true -Value $toolInput -Anomaly $null
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function Get-ClaudeHookToolInputString {
|
|
409
|
+
<#
|
|
410
|
+
.SYNOPSIS
|
|
411
|
+
Read a named string property off an already-extracted tool_input object.
|
|
412
|
+
|
|
413
|
+
.DESCRIPTION
|
|
414
|
+
Property-level tolerance lives here: an absent property returns an empty
|
|
415
|
+
string, which every hook already treats as "out of my scope, allow". This
|
|
416
|
+
helper exists only so the hooks do not each re-implement the StrictMode-safe
|
|
417
|
+
property probe.
|
|
418
|
+
|
|
419
|
+
.OUTPUTS
|
|
420
|
+
System.String
|
|
421
|
+
#>
|
|
422
|
+
[CmdletBinding()]
|
|
423
|
+
[OutputType([string])]
|
|
424
|
+
param(
|
|
425
|
+
[AllowNull()]
|
|
426
|
+
[object] $ToolInput,
|
|
427
|
+
|
|
428
|
+
[Parameter(Mandatory)]
|
|
429
|
+
[string] $Name
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
$value = Get-ClaudeHookEnvelopeValue -Envelope $ToolInput -Name $Name
|
|
433
|
+
if ($null -eq $value) {
|
|
434
|
+
return ''
|
|
435
|
+
}
|
|
436
|
+
return [string]$value
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function Resolve-ClaudeHookToolInput {
|
|
440
|
+
<#
|
|
441
|
+
.SYNOPSIS
|
|
442
|
+
One-call convenience: raw text in, typed tool_input result out.
|
|
443
|
+
|
|
444
|
+
.DESCRIPTION
|
|
445
|
+
Composes ConvertFrom-ClaudeHookEnvelope and Get-ClaudeHookToolInput and
|
|
446
|
+
additionally surfaces the parsed envelope on the Envelope member, because
|
|
447
|
+
enforce-epic-invocation-origin.ps1 needs the envelope root (agent_type)
|
|
448
|
+
alongside the nested tool_input. Returns the envelope-parse anomaly unchanged
|
|
449
|
+
when the parse fails, so a hook maps a single Anomaly value to a single deny
|
|
450
|
+
reason.
|
|
451
|
+
|
|
452
|
+
.PARAMETER Raw
|
|
453
|
+
The raw payload text from Read-ClaudeHookRawPayload.
|
|
454
|
+
.OUTPUTS
|
|
455
|
+
System.Management.Automation.PSCustomObject
|
|
456
|
+
#>
|
|
457
|
+
[CmdletBinding()]
|
|
458
|
+
[OutputType([pscustomobject])]
|
|
459
|
+
param(
|
|
460
|
+
[AllowNull()]
|
|
461
|
+
[AllowEmptyString()]
|
|
462
|
+
[string] $Raw
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
$parsed = ConvertFrom-ClaudeHookEnvelope -Raw $Raw
|
|
466
|
+
if (-not $parsed.IsValid) {
|
|
467
|
+
return [pscustomobject]@{
|
|
468
|
+
IsValid = $false
|
|
469
|
+
Value = $null
|
|
470
|
+
Envelope = $null
|
|
471
|
+
Anomaly = $parsed.Anomaly
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
$extracted = Get-ClaudeHookToolInput -Envelope $parsed.Value
|
|
476
|
+
return [pscustomobject]@{
|
|
477
|
+
IsValid = $extracted.IsValid
|
|
478
|
+
Value = $extracted.Value
|
|
479
|
+
Envelope = $parsed.Value
|
|
480
|
+
Anomaly = $extracted.Anomaly
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
Export-ModuleMember -Function `
|
|
485
|
+
Read-ClaudeHookRawPayload, `
|
|
486
|
+
ConvertFrom-ClaudeHookEnvelope, `
|
|
487
|
+
Get-ClaudeHookToolInput, `
|
|
488
|
+
Resolve-ClaudeHookToolInput, `
|
|
489
|
+
Get-ClaudeHookEnvelopeValue, `
|
|
490
|
+
Test-ClaudeHookEnvelopeHasKey, `
|
|
491
|
+
Test-ClaudeHookObjectValue, `
|
|
492
|
+
Get-ClaudeHookToolInputString, `
|
|
493
|
+
Get-ClaudeHookPayloadAnomalyCode, `
|
|
494
|
+
Get-ClaudeHookPayloadAnomalyReason
|
|
@@ -233,11 +233,69 @@ Three constraints bound the mandate-read exclusion:
|
|
|
233
233
|
detection compares the declared radius against the paths a diff actually touched, so an item that
|
|
234
234
|
wrote an excluded path is caught against observed evidence rather than against prose.
|
|
235
235
|
|
|
236
|
-
The extractor additionally rejects
|
|
236
|
+
The extractor additionally rejects four token shapes that were never write claims: a wildcard-free
|
|
237
237
|
token whose final component names a directory rather than a file, a `docs/features/` glob whose
|
|
238
|
-
wildcard occupies or truncates the feature-folder segment,
|
|
239
|
-
letter
|
|
240
|
-
longer satisfies the shape rules.
|
|
238
|
+
wildcard occupies or truncates the feature-folder segment, a contract token carrying no ASCII
|
|
239
|
+
letter, and a token containing a placeholder or interpolation marker. `artifacts/` is not a known
|
|
240
|
+
top-level segment, so a bare `artifacts/**` subtree claim no longer satisfies the shape rules.
|
|
241
|
+
|
|
242
|
+
### Placeholder-shape rejection (issue #502)
|
|
243
|
+
|
|
244
|
+
The fourth shape is a token containing any member of the marker set
|
|
245
|
+
|
|
246
|
+
```text
|
|
247
|
+
< > ${ $( %
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The set is not a configuration key and is not read from `config/blast-radius.json`. It originates in
|
|
251
|
+
the checkable-literal placeholder guard defined in `.claude/rules/plan-acceptance-gates.md`, which
|
|
252
|
+
uses the identical five markers to decide that a plan operand documents a command *shape* rather
|
|
253
|
+
than stating a real assertion. Both subsystems answer the same question about the same text, so the
|
|
254
|
+
two vocabularies are pinned equal by test rather than left to convention.
|
|
255
|
+
|
|
256
|
+
**A marker-bearing token never matches a tracked path.** A placeholder or interpolation form
|
|
257
|
+
resolves at run time to text that is not in the token, so the token as written names nothing. For
|
|
258
|
+
the two angle brackets the claim is stronger than a heuristic: Windows forbids both characters in a
|
|
259
|
+
filename outright, so an angle-bracketed token cannot name a file on the platform this repository is
|
|
260
|
+
developed on. Admitting such a token recorded a `paths` entry that no diff could ever touch, and two
|
|
261
|
+
items citing the identical shape then acquired a `path_overlap` edge on a string that resolves to
|
|
262
|
+
nothing.
|
|
263
|
+
|
|
264
|
+
**The dominant token is a mandated artifact shape, which is why the exposure was corpus-wide rather
|
|
265
|
+
than incidental.** Every agent in this repository is instructed to write its evidence to the
|
|
266
|
+
canonical scheme defined in `.claude/skills/evidence-and-timestamp-conventions/SKILL.md`, and that
|
|
267
|
+
scheme is non-overridable, so a well-formed plan quotes the same feature-relative artifact shape
|
|
268
|
+
that every other well-formed plan quotes. The shape is therefore the most widely shared token in the
|
|
269
|
+
corpus, and admitting it made compliance with the evidence-path scheme a source of contention. The
|
|
270
|
+
same reasoning applies to the feature-document shapes and to the session-keyed state-file shape.
|
|
271
|
+
|
|
272
|
+
**The planner remains obliged to enumerate a genuine write explicitly.** This rejection describes a
|
|
273
|
+
plan that documents a shape, not a permanent ban on the paths a shape resembles. When an item's plan
|
|
274
|
+
will actually write a path it expressed as a shape, the planner appends that exact concrete path to
|
|
275
|
+
the declared radius after normalization. This is the same obligation constraint 1 of the
|
|
276
|
+
mandate-read exclusion imposes, and `detect_escaped_paths` is the same backstop: drift detection
|
|
277
|
+
compares the declared radius against the paths a diff actually touched, so an item that wrote a path
|
|
278
|
+
it expressed only as a shape is caught against observed evidence rather than against prose.
|
|
279
|
+
|
|
280
|
+
**Accepted fail-open trade.** The rejection runs inside the classifier, upstream of shared-surface
|
|
281
|
+
resolution, so a marker-bearing token whose shape matches a configured `shared_surface_globs`
|
|
282
|
+
pattern is dropped before it can be reported as a touched shared surface. The trade is inherent to
|
|
283
|
+
that placement: a guard downstream of surface resolution would leave the token in `paths` and
|
|
284
|
+
reintroduce the path-level false edge it exists to remove. Corpus exposure was measured empty — no
|
|
285
|
+
plan in the 58-plan corpus examined for issue #502 cited a marker-bearing token whose shape matched a
|
|
286
|
+
configured shared-surface glob — and the planner obligation above is what keeps a genuine write
|
|
287
|
+
visible. A later change that widens the marker set must re-take that measurement.
|
|
288
|
+
|
|
289
|
+
**Known residual: the whitespace split.** Extraction harvests whitespace-free inline-code tokens, so
|
|
290
|
+
a placeholder form written with internal whitespace is split into fragments before the marker test
|
|
291
|
+
ever runs. Each fragment is then judged on its own shape and is normally rejected for an unrelated
|
|
292
|
+
reason, most often for carrying no separator. The residual is therefore benign in the accepting
|
|
293
|
+
direction but is recorded here because it means the rejection is not a complete guard against every
|
|
294
|
+
way a shape can be written; it is a guard against the whitespace-free forms the extractor actually
|
|
295
|
+
admits.
|
|
296
|
+
|
|
297
|
+
Enforcement of this rejection is prose plus validator logic, exactly as for the three shapes above.
|
|
298
|
+
No JSON Schema is authored, imported, or read for it, and `config/blast-radius.json` gains no key.
|
|
241
299
|
|
|
242
300
|
### Module-map granularity criterion
|
|
243
301
|
|
|
@@ -257,6 +315,69 @@ weakens the relation below the path level: two items editing the same file still
|
|
|
257
315
|
A candidate module belongs in the map when it names a subsystem an item could plausibly not touch.
|
|
258
316
|
A candidate that matches the majority of work items belongs nowhere.
|
|
259
317
|
|
|
318
|
+
### The published truth table is not a copy of this one (issue #500)
|
|
319
|
+
|
|
320
|
+
The push-down publishes a second truth table into a destination workspace at
|
|
321
|
+
`extensions/drm-copilot/resources/claude-customizations/config/blast-radius.json`. That copy stood
|
|
322
|
+
stale after issue #489 corrected only the self-hosted one, and correcting it fixed contention in
|
|
323
|
+
both directions at once. Three points fix the relation between the two copies so a later maintainer
|
|
324
|
+
does not re-synchronise them by hand.
|
|
325
|
+
|
|
326
|
+
**A destination's module map is DERIVED, so the bundled `modules` key is not consumed.**
|
|
327
|
+
`assembleModules` in `extensions/drm-copilot/src/lib/push-down/claude-blast-radius-derive-core.ts`
|
|
328
|
+
computes a destination's module map from the destination's OWN layout — the manifest-bearing
|
|
329
|
+
directories its scan observes — unioned with `PAYLOAD_MODULES`. It never reads the source document's
|
|
330
|
+
`modules` key. The bundled `modules` key is retained rather than deleted only so that a maintainer
|
|
331
|
+
reading the file is not told something false, and because
|
|
332
|
+
`tests/scripts/dev_tools/test_blast_radius_config.py` calls `load_module_globs` on it and that
|
|
333
|
+
helper raises on an absent key. Nothing schedules on it.
|
|
334
|
+
|
|
335
|
+
**`PAYLOAD_MODULES` carries `config` only.** `claude-runtime` was removed from it by the same
|
|
336
|
+
granularity criterion that removed it from this repository's own map. The criterion transfers
|
|
337
|
+
without modification: every agent in the runtime is instructed to read the policy rules and process
|
|
338
|
+
skills before doing any work, so a `.claude/**` umbrella matches nearly every radius in a
|
|
339
|
+
destination exactly as it did here. The no-signal floor is preserved because `config/**` in a
|
|
340
|
+
destination holds only the two published files, which makes `config` a subsystem an item can
|
|
341
|
+
plausibly not touch and keeps the assembled map non-empty so the forbidden-glob guard has a
|
|
342
|
+
non-vacuous input.
|
|
343
|
+
|
|
344
|
+
**The bundled `shared_surfaces` and `shared_surface_globs` sets are the destination-portable
|
|
345
|
+
subset, not a copy of the self-hosted sets.** They were authored narrow when the bundled copy was
|
|
346
|
+
created and were never a copy that fell behind, so the correct gate is portable-set equality against
|
|
347
|
+
a declared constant plus a subset relation against the self-hosted list — never byte-equality with
|
|
348
|
+
the self-hosted file. Only `version`, `over_breadth_fraction`, and `mandate_reads` are byte-equal
|
|
349
|
+
across the two copies.
|
|
350
|
+
|
|
351
|
+
The reason the two key groups take different relations is an asymmetry between surfaces and modules.
|
|
352
|
+
An over-matching MODULE glob costs concurrency on every pair of items it touches, because a module
|
|
353
|
+
that fires for both radii forces contention whether or not the items are related. A SURFACE or
|
|
354
|
+
mandate-read entry naming a path the destination lacks is inert: it matches nothing, so it costs
|
|
355
|
+
nothing. Erring wide is therefore free on the surface side and expensive on the module side, which
|
|
356
|
+
is why the portable surface set carries ecosystem-standard root filenames a given destination may
|
|
357
|
+
not have. A separator-free shared surface carries additional weight: it is the sole gate on whether
|
|
358
|
+
the path-token extractor accepts a separator-free token at all, so a published table with no
|
|
359
|
+
separator-free surface entry cannot detect two items rewriting the same root build file, whatever
|
|
360
|
+
that file is named.
|
|
361
|
+
|
|
362
|
+
**A directional invariant closes the residual Class 2 gap (issue #500 remediation).** Portable-set
|
|
363
|
+
equality against the declared portable-surface constant and the `bundled <= self_hosted` subset
|
|
364
|
+
relation together do not observe the self-hosted copy gaining a portable separator-free surface
|
|
365
|
+
that never reaches the bundle: both checks are satisfied by a bundled set that stays fixed while
|
|
366
|
+
the self-hosted set grows around it. `test_every_separator_free_self_hosted_shared_surface_reaches_the_bundle`
|
|
367
|
+
in `tests/scripts/dev_tools/test_blast_radius_config_parity.py`, mirrored in
|
|
368
|
+
`tests/scripts/claude-lib/blast-radius/BlastRadius.KeyPartition.Tests.ps1`, closes that gap
|
|
369
|
+
structurally by asserting the reverse containment for separator-free entries: every separator-free
|
|
370
|
+
self-hosted `shared_surfaces` entry must also appear in the bundled separator-free set.
|
|
371
|
+
|
|
372
|
+
**The key-partition gate now asserts exhaustiveness (issue #500 remediation, R8).** The three
|
|
373
|
+
declared classes each assert a property of the keys they name, but none of them asserted that
|
|
374
|
+
the two committed copies' top-level key sets are identical, or that every top-level key
|
|
375
|
+
belongs to one of the three declared classes. `test_every_top_level_key_is_classified_and_shared_by_both_copies`
|
|
376
|
+
in `tests/scripts/dev_tools/test_blast_radius_config_parity.py`, mirrored in
|
|
377
|
+
`tests/scripts/claude-lib/blast-radius/BlastRadius.KeyPartition.Tests.ps1`, closes that gap: the
|
|
378
|
+
union of both copies' top-level keys is exhaustively covered by the three declared classes, and
|
|
379
|
+
an unclassified key or a key present in only one copy fails loudly and names itself.
|
|
380
|
+
|
|
260
381
|
## Enforcement
|
|
261
382
|
|
|
262
383
|
- `scripts/dev_tools/validate_parallel_orchestrator_state.py`, with the helper modules `scripts/dev_tools/_parallel_state_common.py`, `scripts/dev_tools/_parallel_state_structures.py`, and `scripts/dev_tools/_parallel_state_records.py`, appends one error per violated orchestrator invariant. The completion-gate invariants 20 and 21 run only when the caller passes `require_complete=True`.
|
|
@@ -173,6 +173,33 @@ integration-to-main PR.
|
|
|
173
173
|
The `## Invocation Prompt` section is the exact text the user replays (from the main session,
|
|
174
174
|
never from an `orchestrator` agent) to launch execution.
|
|
175
175
|
|
|
176
|
+
### Integration Commit Form (issue #539)
|
|
177
|
+
|
|
178
|
+
The epic planner's own version-control operations — committing the epic manifest, the fanned-in
|
|
179
|
+
child feature folders and approved plans, and the durable kickoff copy — are orchestration
|
|
180
|
+
bookkeeping, not implementation. The preimplementation gate
|
|
181
|
+
(`.claude/hooks/enforce-orchestration-preimplementation-gate.ps1`) exempts them only when the
|
|
182
|
+
invocation is **pathspec-bearing**: every staging or integration invocation must name at least one
|
|
183
|
+
explicit path operand, and every operand must resolve inside one of the five exempt
|
|
184
|
+
orchestration-bookkeeping trees:
|
|
185
|
+
|
|
186
|
+
- `docs/features/epics/`
|
|
187
|
+
- `docs/features/parallel/`
|
|
188
|
+
- `docs/features/active/`
|
|
189
|
+
- `docs/features/potential/`
|
|
190
|
+
- `artifacts/orchestration/`
|
|
191
|
+
|
|
192
|
+
Use one of the two pathspec-bearing spellings:
|
|
193
|
+
|
|
194
|
+
- the message option followed by a double-dash separator and then the exempt path operands, or
|
|
195
|
+
- the exempt path operands named directly before the message option.
|
|
196
|
+
|
|
197
|
+
A pathless integration invocation — a message option with no path operand — is denied (D4 row 4),
|
|
198
|
+
as are whole-tree operands, tree-wide flags, pathspec-from-file options, history-rewriting or
|
|
199
|
+
content-widening options, and any operand that resolves outside the five exempt trees. Mixing one
|
|
200
|
+
exempt operand with one production operand denies the whole invocation. Name each exempt path
|
|
201
|
+
explicitly; the exemption is allow-side only and every parse ambiguity denies.
|
|
202
|
+
|
|
176
203
|
## Checkpoint Handling
|
|
177
204
|
|
|
178
205
|
Persist `artifacts/orchestration/epic-planner-state.json` after every completed step with the
|