@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,9 +4,9 @@
4
4
 
5
5
  .DESCRIPTION
6
6
  Invoked by the Claude Code PreToolUse hook before any Bash command runs. Reads
7
- tool input JSON from the CLAUDE_TOOL_INPUT environment variable, inspects the
8
- attempted command, and blocks gh pr create / gh pr edit commands that bypass the
9
- pr-author skill workflow.
7
+ the hook payload through the shared reader, extracts the attempted command from the
8
+ envelope's nested tool_input, and blocks gh pr create / gh pr edit commands that
9
+ bypass the pr-author skill workflow.
10
10
 
11
11
  Required sequence:
12
12
  1. mcp__drm-copilot__collect_pr_context writes artifacts/pr_context.summary.txt
@@ -43,6 +43,8 @@
43
43
  [CmdletBinding()]
44
44
  param()
45
45
 
46
+
47
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
46
48
  $script:PrContextArtifactPath = 'artifacts/pr_context.summary.txt'
47
49
  $script:OrchestratorStateCheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
48
50
 
@@ -141,235 +143,39 @@ function Get-PrContextSummaryLastWriteUtc {
141
143
 
142
144
  . (Join-Path $PSScriptRoot 'enforce-pr-author-skill.epic-base-branch.ps1')
143
145
 
144
- function Test-PrAuthorReceiptVerification {
145
- <#
146
- .SYNOPSIS
147
- Verify the SHA-256 receipt and return a block-reason string, or $null when verified.
148
- .DESCRIPTION
149
- Runs the six ordered receipt checks on the --body-file-with-context path. Each check is its
150
- own short-circuiting branch; the first failure returns its reason code:
151
- 1. PR_BODY_PATH_NONCANONICAL - --body-file path does not match the canonical
152
- artifacts/pr_body_<N>.md pattern (case-sensitive).
153
- 2. PR_AUTHOR_RECEIPT_MISSING - sibling artifacts/pr_body_<N>.receipt.json absent.
154
- 3. PR_AUTHOR_RECEIPT_NUMBER_MISMATCH- receipt.number (integer) != <N> from the path.
155
- 4. PR_AUTHOR_RECEIPT_HASH_MISMATCH - inline SHA-256 (lowercase hex) of the body bytes
156
- != receipt.sha256.
157
- 5. PR_AUTHOR_RECEIPT_STALE - receipt.created_at (UTC) not strictly newer than the
158
- context summary last-write time.
159
- 6. EPIC_BASE_BRANCH_MISMATCH - under epic_mode, gh pr create does not carry a
160
- matching --base <epic_context.integration_branch>.
161
- Returns $null when all six checks pass (allow). All disk access flows through the four
162
- injectable seams (Get-PrBodyFileBytes, Get-PrAuthorReceiptContent,
163
- Get-PrContextSummaryLastWriteUtc, Get-PrAuthorCheckpointContent); SHA-256 is computed
164
- inline. This is a policy-level integrity check, not a cryptographic control: any actor
165
- with Write access to artifacts/ can replace the body file and the receipt together.
166
- .PARAMETER CommandText
167
- The Bash command text containing the --body-file argument.
168
- .OUTPUTS
169
- System.String or $null
170
- #>
171
- [CmdletBinding()]
172
- [OutputType([string])]
173
- param(
174
- [Parameter(Mandatory)]
175
- [string] $CommandText
176
- )
177
-
178
- # Check 1: the --body-file argument must match the canonical artifacts/pr_body_<N>.md pattern.
179
- # The match is case-sensitive (-cmatch) so a non-canonical path is rejected before any read.
180
- if ($CommandText -cnotmatch '--body-file\s+artifacts/pr_body_(\d+)\.md\b') {
181
- return "PR_BODY_PATH_NONCANONICAL: ``--body-file`` must reference a canonical ``artifacts/pr_body_<N>.md`` file produced by the pr-author skill. The path supplied does not match ``artifacts/pr_body_<N>.md``."
182
- }
183
-
184
- $bodyNumber = [int]$Matches[1]
185
- $bodyFilePath = "artifacts/pr_body_$bodyNumber.md"
186
- $receiptFilePath = "artifacts/pr_body_$bodyNumber.receipt.json"
187
-
188
- # Check 2: the sibling receipt file must exist (read via the injectable seam).
189
- $receiptRaw = Get-PrAuthorReceiptContent -ReceiptFilePath $receiptFilePath
190
- if ([string]::IsNullOrWhiteSpace($receiptRaw)) {
191
- return "PR_AUTHOR_RECEIPT_MISSING: ``$receiptFilePath`` is absent. The pr-author agent must write the SHA-256 receipt alongside ``$bodyFilePath`` before issuing ``gh pr create``/``gh pr edit --body-file``."
192
- }
193
-
194
- # A receipt that is present but not valid JSON cannot be verified; treat it as missing content.
195
- try {
196
- $receipt = $receiptRaw | ConvertFrom-Json -ErrorAction Stop
197
- } catch {
198
- return "PR_AUTHOR_RECEIPT_MISSING: ``$receiptFilePath`` is not valid JSON. The pr-author agent must write a well-formed receipt with ``number``, ``sha256``, and ``created_at``."
199
- }
200
-
201
- # Check 3: the receipt number must equal the <N> extracted from the canonical path.
202
- $receiptNumber = $null
203
- $parsedNumber = 0
204
- if ([int]::TryParse([string]$receipt.number, [ref] $parsedNumber)) {
205
- $receiptNumber = $parsedNumber
206
- }
207
- if ($receiptNumber -ne $bodyNumber) {
208
- return "PR_AUTHOR_RECEIPT_NUMBER_MISMATCH: ``$receiptFilePath`` ``number`` ($($receipt.number)) does not equal the body-file number ($bodyNumber). The receipt must bind to ``$bodyFilePath``."
209
- }
210
-
211
- # Check 4: the inline SHA-256 (lowercase hex) of the body bytes must equal receipt.sha256.
212
- $bodyBytes = Get-PrBodyFileBytes -BodyFilePath $bodyFilePath
213
- if ($null -eq $bodyBytes) {
214
- return "PR_AUTHOR_RECEIPT_HASH_MISMATCH: ``$bodyFilePath`` could not be read to verify its SHA-256 against ``$receiptFilePath``."
215
- }
216
-
217
- $sha256 = [System.Security.Cryptography.SHA256]::Create()
218
- try {
219
- $hashBytes = $sha256.ComputeHash($bodyBytes)
220
- } finally {
221
- $sha256.Dispose()
222
- }
223
- $computedHash = ([System.BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
224
-
225
- if ($computedHash -ne ([string]$receipt.sha256).ToLowerInvariant()) {
226
- return "PR_AUTHOR_RECEIPT_HASH_MISMATCH: the SHA-256 of ``$bodyFilePath`` does not equal ``sha256`` in ``$receiptFilePath``. The body file was modified after the receipt was written."
227
- }
228
-
229
- # Check 5: receipt.created_at (UTC) must be strictly newer than the context summary last-write.
230
- $createdAt = [DateTime]::MinValue
231
- $createdParsed = [DateTime]::TryParse(
232
- [string]$receipt.created_at,
233
- [System.Globalization.CultureInfo]::InvariantCulture,
234
- [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal,
235
- [ref] $createdAt)
236
- if (-not $createdParsed) {
237
- return "PR_AUTHOR_RECEIPT_STALE: ``$receiptFilePath`` has a missing or unparseable ``created_at``. The pr-author agent must record a UTC ISO-8601 ``created_at`` strictly newer than ``$script:PrContextArtifactPath``."
238
- }
239
-
240
- $contextLastWrite = Get-PrContextSummaryLastWriteUtc
241
- if (($null -eq $contextLastWrite) -or ($createdAt -le $contextLastWrite)) {
242
- return "PR_AUTHOR_RECEIPT_STALE: ``$receiptFilePath`` ``created_at`` is not strictly newer than the last-write time of ``$script:PrContextArtifactPath``. The pr-author agent must regenerate the body and receipt after refreshing the PR context."
243
- }
244
-
245
- # Check 6: under epic_mode, gh pr create must carry a matching --base override.
246
- $epicBaseBranchReason = Test-EpicBaseBranchOverride -CommandText $CommandText
247
- if ($epicBaseBranchReason) {
248
- return $epicBaseBranchReason
249
- }
250
-
251
- return $null
252
- }
253
-
254
- function Get-PrAuthorBypassReason {
255
- <#
256
- .SYNOPSIS
257
- Inspect the command text and return a block reason string, or $null when the command is allowed.
258
- .DESCRIPTION
259
- Returns PR_AUTHOR_SKILL_BLOCKED when gh pr create or gh pr edit is run with --body (inline,
260
- no --body-file), or when gh pr create is run with no body flag at all. Returns
261
- PR_CONTEXT_MISSING when --body-file is present but the context artifact does not exist on
262
- disk. When --body-file is present and the context artifact exists, verifies the SHA-256
263
- receipt via Test-PrAuthorReceiptVerification and returns its block reason
264
- (PR_BODY_PATH_NONCANONICAL / PR_AUTHOR_RECEIPT_*) when verification fails. Returns $null for
265
- all allowed patterns. Cases A, B, and C are evaluated first and unchanged; receipt
266
- verification only extends the previously-allowed --body-file-with-context path.
267
- .PARAMETER CommandText
268
- The Bash command text extracted from CLAUDE_TOOL_INPUT.
269
- .PARAMETER ContextExists
270
- Whether artifacts/pr_context.summary.txt currently exists on disk.
271
- .OUTPUTS
272
- System.String or $null
273
- #>
274
- [CmdletBinding()]
275
- [OutputType([string])]
276
- param(
277
- [Parameter(Mandatory)]
278
- [string] $CommandText,
279
-
280
- [Parameter(Mandatory)]
281
- [bool] $ContextExists
282
- )
283
-
284
- # Only act on gh pr create or gh pr edit subcommands.
285
- $isPrCreate = $CommandText -match '(?i)\bgh\s+pr\s+create\b'
286
- $isPrEdit = $CommandText -match '(?i)\bgh\s+pr\s+edit\b'
287
-
288
- if (-not $isPrCreate -and -not $isPrEdit) {
289
- return $null
290
- }
291
-
292
- $hasBodyFile = $CommandText -match '(?i)--body-file\b'
293
- $hasInlineBody = $CommandText -match '(?i)--body(?!-file)\b'
294
-
295
- # Case A: gh pr create OR gh pr edit with inline --body (not --body-file). Evaluated before the
296
- # gh pr edit no-body allow short-circuit so inline-body edits are blocked, not allowed.
297
- if (($isPrCreate -or $isPrEdit) -and $hasInlineBody -and -not $hasBodyFile) {
298
- return "PR_AUTHOR_SKILL_BLOCKED: ``gh pr create`` and ``gh pr edit`` must use ``--body-file`` with a file produced by the pr-author skill from ``$script:PrContextArtifactPath``. Run ``mcp__drm-copilot__collect_pr_context`` to generate the context file, apply the pr-author skill to produce ``artifacts/pr_body_<N>.md``, then pass that file via ``--body-file``."
299
- }
300
-
301
- if ($isPrCreate) {
302
- # Case B: gh pr create with no body flag at all.
303
- if (-not $hasInlineBody -and -not $hasBodyFile) {
304
- return "PR_AUTHOR_SKILL_BLOCKED: New PRs require ``--body-file``. Run ``mcp__drm-copilot__collect_pr_context`` to generate ``$script:PrContextArtifactPath``, apply the pr-author skill to produce ``artifacts/pr_body_<N>.md``, then pass that file via ``--body-file``."
305
- }
306
- }
307
-
308
- if ($isPrEdit) {
309
- # gh pr edit with no --body or --body-file (e.g., --title, --add-label, --reviewer) is allowed.
310
- if (-not $hasInlineBody -and -not $hasBodyFile) {
311
- return $null
312
- }
313
- }
314
-
315
- # Case C: --body-file present but context artifact is absent.
316
- if ($hasBodyFile -and -not $ContextExists) {
317
- return "PR_CONTEXT_MISSING: ``$script:PrContextArtifactPath`` is absent. Run ``mcp__drm-copilot__collect_pr_context`` before creating or editing the PR body."
318
- }
319
-
320
- # Orchestrator-state preflight: runs inside this same PreToolUse hook (so it cannot be
321
- # bypassed by invoking gh pr create/edit directly) before receipt verification.
322
- if ($hasBodyFile -and $ContextExists) {
323
- $preflightResult = Invoke-OrchestratorStatePreflight -CheckpointPath $script:OrchestratorStateCheckpointPath
324
- if ($preflightResult.HasErrors) {
325
- $preflightSummary = if ([string]::IsNullOrWhiteSpace($preflightResult.ErrorText)) {
326
- "checkpoint missing at $script:OrchestratorStateCheckpointPath"
327
- } else {
328
- $preflightResult.ErrorText
329
- }
330
- return "ORCHESTRATOR_STATE_PREFLIGHT_FAILED: $preflightSummary"
331
- }
332
- }
333
-
334
- # Receipt verification: extends, and does not replace, the previously-allowed path.
335
- if ($hasBodyFile -and $ContextExists) {
336
- $receiptReason = Test-PrAuthorReceiptVerification -CommandText $CommandText
337
- if ($receiptReason) {
338
- return $receiptReason
339
- }
340
- }
341
-
342
- return $null
343
- }
146
+ # Dot-source the receipt-verification and bypass-reason helpers. Guarded so dot-sourcing this
147
+ # hook in tests loads the helpers too (issue #501 headroom split).
148
+ . (Join-Path $PSScriptRoot 'enforce-pr-author-skill-helpers.ps1')
344
149
 
345
150
  function Invoke-PrAuthorSkillDecision {
346
151
  <#
347
152
  .SYNOPSIS
348
- Parse CLAUDE_TOOL_INPUT and return an allow-or-block decision.
153
+ Parse the PreToolUse envelope and return an allow-or-block decision.
154
+ .DESCRIPTION
155
+ Envelope-level anomalies fail closed as a deny; missing command text inside a
156
+ well-formed tool_input remains an allow, because that is this gate's scope filter.
349
157
  .PARAMETER ToolInputRaw
350
- The raw JSON tool payload supplied by Claude Code.
158
+ The raw JSON hook payload acquired by Read-ClaudeHookRawPayload.
351
159
  .OUTPUTS
352
160
  System.Collections.Specialized.OrderedDictionary
353
- .NOTES
354
- Missing tool input or missing command text is treated as allow.
355
161
  #>
356
162
  [CmdletBinding()]
357
163
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
358
164
  param(
165
+ [AllowNull()]
166
+ [AllowEmptyString()]
359
167
  [string] $ToolInputRaw
360
168
  )
361
169
 
362
- if (-not $ToolInputRaw) {
363
- return Get-PrAuthorSkillAllowDecision
170
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
171
+ if (-not $payload.IsValid) {
172
+ return Get-PrAuthorSkillBlockDecision -Reason (
173
+ 'PR_AUTHOR_SKILL_BLOCKED: payload anomaly - ' +
174
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $payload.Anomaly) +
175
+ '. The gate fails closed on an envelope it cannot read.')
364
176
  }
365
177
 
366
- try {
367
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
368
- } catch {
369
- throw "enforce-pr-author-skill hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
370
- }
371
-
372
- $commandText = $toolInput.command
178
+ $commandText = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'command'
373
179
  if (-not $commandText) {
374
180
  return Get-PrAuthorSkillAllowDecision
375
181
  }
@@ -433,7 +239,7 @@ function Test-PrAuthorBypassRequired {
433
239
  .SYNOPSIS
434
240
  Return $true when a Bash command requires the pr-author skill to run first.
435
241
  .PARAMETER CommandText
436
- The Bash command text extracted from CLAUDE_TOOL_INPUT.
242
+ The Bash command text extracted from the envelope's tool_input.
437
243
  .PARAMETER ContextExists
438
244
  Whether artifacts/pr_context.summary.txt currently exists on disk.
439
245
  .OUTPUTS
@@ -452,18 +258,55 @@ function Test-PrAuthorBypassRequired {
452
258
  return ($null -ne (Get-PrAuthorBypassReason -CommandText $CommandText -ContextExists $ContextExists))
453
259
  }
454
260
 
261
+ function Invoke-PrAuthorSkillEntryPoint {
262
+ <#
263
+ .SYNOPSIS
264
+ Runs the hook decision and returns the process exit code.
265
+ .DESCRIPTION
266
+ Acquires the payload through the shared reader unless the caller supplies
267
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
268
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
269
+ decision by the time control reaches here. The function does not call exit;
270
+ the thin tail converts the returned code into a process exit.
271
+ .PARAMETER ToolInputRaw
272
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
273
+ .PARAMETER ReadPayload
274
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
275
+ case without touching a console.
276
+ .OUTPUTS
277
+ System.Int32
278
+ #>
279
+ [CmdletBinding()]
280
+ [OutputType([int])]
281
+ param(
282
+ [AllowNull()]
283
+ [AllowEmptyString()]
284
+ [string] $ToolInputRaw,
285
+
286
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
287
+ )
288
+
289
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
290
+ $ToolInputRaw = [string](& $ReadPayload)
291
+ }
292
+
293
+ $decision = Invoke-PrAuthorSkillDecision -ToolInputRaw $ToolInputRaw
294
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
295
+
296
+ return 0
297
+ }
298
+
455
299
  # Allow dot-sourcing in tests without executing the entrypoint.
456
300
  if ($MyInvocation.InvocationName -eq '.') {
457
301
  return
458
302
  }
459
303
 
460
- try {
461
- $decision = Invoke-PrAuthorSkillDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
462
- } catch {
463
- Write-Error $_
464
- exit 1
304
+ # The entry point returns its [int] exit code as the last pipeline element and the
305
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
306
+ # expression and emit nothing, so the decision is written explicitly here first.
307
+ $entryPointResult = @(Invoke-PrAuthorSkillEntryPoint)
308
+ if ($entryPointResult.Count -gt 1) {
309
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
465
310
  }
466
311
 
467
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
468
-
469
- exit 0
312
+ exit ([int]$entryPointResult[-1])
@@ -1,12 +1,15 @@
1
1
  <#
2
2
  .SYNOPSIS
3
3
  Pre-tool-use hook that blocks atomic-planner delegations when the target
4
- feature folder does not yet contain spec.md and user-story.md.
4
+ feature folder does not yet contain the prd-feature outputs its persisted
5
+ work mode requires.
5
6
 
6
7
  .DESCRIPTION
7
8
  Invoked by the Claude Code PreToolUse hook on the Agent (Task) tool. Reads
8
- tool input JSON from the CLAUDE_TOOL_INPUT environment variable. Activates
9
- only when subagent_type is 'atomic-planner'.
9
+ the tool payload through the shared hook-payload reader (stdin first, with
10
+ environment-variable fallback) and takes the tool arguments from the
11
+ envelope's nested tool_input object. Activates only when subagent_type is
12
+ 'atomic-planner'.
10
13
 
11
14
  Feature folder resolution order:
12
15
  1. Scan the prompt text for any path matching
@@ -18,11 +21,25 @@
18
21
  3. If neither yields a folder, block with a reason instructing the caller
19
22
  to reference a feature folder explicitly.
20
23
 
21
- Once the folder is resolved, the hook verifies that both spec.md and
22
- user-story.md exist in that folder. If either is missing, the script emits a
23
- PreToolUse JSON response with hookSpecificOutput.permissionDecision='deny'
24
- and a reason naming the missing file(s) and instructing the orchestrator to
25
- invoke prd-feature first. Allowed delegations emit
24
+ Once the folder is resolved, the hook reads the persisted work-mode marker
25
+ (`- Work Mode: minor-audit|full-feature|full-bug|full`) from that folder's
26
+ issue.md, per the mode contract in
27
+ .claude/skills/feature-promotion-lifecycle/SKILL.md, and derives the
28
+ required prerequisite set:
29
+ - full-feature -> spec.md and user-story.md are both required.
30
+ - full-bug -> spec.md only is required.
31
+ - minor-audit -> neither is required; issue.md carries the acceptance
32
+ criteria for this mode.
33
+ - marker absent, unreadable, or unrecognized -> fail closed to the
34
+ strictest set (spec.md and user-story.md), and the block reason states
35
+ that the work mode could not be determined so the operator can tell
36
+ this case apart from a genuine missing prerequisite. The legacy `full`
37
+ marker normalizes to full-feature's requirement set.
38
+
39
+ If any required file is missing, the script emits a PreToolUse JSON
40
+ response with hookSpecificOutput.permissionDecision='deny' and a reason
41
+ naming the missing file(s) and instructing the orchestrator to invoke
42
+ prd-feature first. Allowed delegations emit
26
43
  hookSpecificOutput.permissionDecision='allow'.
27
44
 
28
45
  Filesystem reads and orchestrator-state lookups go through wrapper functions
@@ -34,6 +51,8 @@
34
51
  [CmdletBinding()]
35
52
  param()
36
53
 
54
+
55
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
37
56
  function Get-PrdFeatureFileExistence {
38
57
  <#
39
58
  .SYNOPSIS
@@ -49,6 +68,96 @@ function Get-PrdFeatureFileExistence {
49
68
  return [bool](Test-Path -LiteralPath $Path -PathType Leaf)
50
69
  }
51
70
 
71
+ function Get-PrdFeatureIssueContent {
72
+ <#
73
+ .SYNOPSIS
74
+ Wrapper around Get-Content for a feature folder's issue.md. Tests mock
75
+ this directly to inject work-mode marker content without touching disk.
76
+ #>
77
+ [CmdletBinding()]
78
+ [OutputType([string])]
79
+ param(
80
+ [Parameter(Mandatory)]
81
+ [string] $FeatureFolder
82
+ )
83
+
84
+ $issuePath = "$FeatureFolder/issue.md"
85
+ if (-not (Test-Path -LiteralPath $issuePath -PathType Leaf)) {
86
+ return $null
87
+ }
88
+
89
+ try {
90
+ return Get-Content -LiteralPath $issuePath -Raw -ErrorAction Stop
91
+ }
92
+ catch {
93
+ return $null
94
+ }
95
+ }
96
+
97
+ function Resolve-PrdFeatureWorkMode {
98
+ <#
99
+ .SYNOPSIS
100
+ Parses the persisted `- Work Mode: ...` marker out of issue.md content
101
+ and returns the canonical mode, or $null when the marker is absent,
102
+ unreadable, or unrecognized.
103
+ .DESCRIPTION
104
+ Recognizes minor-audit, full-feature, full-bug, and the legacy full
105
+ marker (normalized to full-feature), mirroring the regex convention
106
+ used by scripts/dev_tools/prompt_mode_contract.py so both runtimes
107
+ agree on what counts as a valid marker line.
108
+ #>
109
+ [CmdletBinding()]
110
+ [OutputType([string])]
111
+ param(
112
+ [AllowNull()]
113
+ [string] $IssueContent
114
+ )
115
+
116
+ if ([string]::IsNullOrWhiteSpace($IssueContent)) {
117
+ return $null
118
+ }
119
+
120
+ $match = [regex]::Match($IssueContent, '(?im)^-\s*Work Mode:\s*(minor-audit|full-feature|full-bug|full)\s*$')
121
+ if (-not $match.Success) {
122
+ return $null
123
+ }
124
+
125
+ $rawMode = $match.Groups[1].Value
126
+ if ($rawMode -eq 'full') {
127
+ return 'full-feature'
128
+ }
129
+ return $rawMode
130
+ }
131
+
132
+ function Get-PrdFeatureRequiredFile {
133
+ <#
134
+ .SYNOPSIS
135
+ Maps a resolved work mode to the set of prd-feature output files the
136
+ target folder must contain before an atomic-planner delegation is
137
+ allowed.
138
+ .DESCRIPTION
139
+ full-feature requires spec.md and user-story.md; full-bug requires
140
+ spec.md only; minor-audit requires neither. A $null or unrecognized
141
+ mode fails closed to the strictest set (spec.md and user-story.md) so
142
+ an undeterminable mode never becomes permissive.
143
+ #>
144
+ [CmdletBinding()]
145
+ [OutputType([string[]])]
146
+ param(
147
+ [AllowNull()]
148
+ [string] $WorkMode
149
+ )
150
+
151
+ # Route on the canonical mode; anything outside the three known values
152
+ # (including $null) falls through to the fail-closed default case.
153
+ switch ($WorkMode) {
154
+ 'full-feature' { return [string[]]@('spec.md', 'user-story.md') }
155
+ 'full-bug' { return [string[]]@('spec.md') }
156
+ 'minor-audit' { return [string[]]@() }
157
+ default { return [string[]]@('spec.md', 'user-story.md') }
158
+ }
159
+ }
160
+
52
161
  function Get-PrdFeatureCheckpointFolder {
53
162
  <#
54
163
  .SYNOPSIS
@@ -126,19 +235,22 @@ function Find-PrdFeatureFolderFromPrompt {
126
235
  function Get-PrdFeatureMissingFile {
127
236
  <#
128
237
  .SYNOPSIS
129
- Returns the list of required files (spec.md, user-story.md) missing in
130
- the target folder.
238
+ Returns the subset of $RequiredFile that is missing from the target
239
+ folder.
131
240
  #>
132
241
  [CmdletBinding()]
133
242
  [OutputType([string[]])]
134
243
  param(
135
244
  [Parameter(Mandatory)]
136
- [string] $FeatureFolder
245
+ [string] $FeatureFolder,
246
+
247
+ [Parameter(Mandatory)]
248
+ [AllowEmptyCollection()]
249
+ [string[]] $RequiredFile
137
250
  )
138
251
 
139
- $required = @('spec.md', 'user-story.md')
140
252
  [System.Collections.Generic.List[string]] $missing = [System.Collections.Generic.List[string]]::new()
141
- foreach ($name in $required) {
253
+ foreach ($name in $RequiredFile) {
142
254
  $candidate = "$FeatureFolder/$name"
143
255
  if (-not (Get-PrdFeatureFileExistence -Path $candidate)) {
144
256
  $missing.Add($name)
@@ -150,7 +262,7 @@ function Get-PrdFeatureMissingFile {
150
262
  function Invoke-PrdFeatureBeforePlannerDecision {
151
263
  <#
152
264
  .SYNOPSIS
153
- Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
265
+ Parses the envelope's nested tool_input and returns an allow-or-block decision.
154
266
  #>
155
267
  [CmdletBinding()]
156
268
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -158,23 +270,25 @@ function Invoke-PrdFeatureBeforePlannerDecision {
158
270
  [string] $ToolInputRaw
159
271
  )
160
272
 
161
- if (-not $ToolInputRaw) {
162
- return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
163
- }
164
-
165
- try {
166
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
167
- }
168
- catch {
169
- throw "enforce-prd-feature-before-planner hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
273
+ $envelope = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
274
+ if (-not $envelope.IsValid) {
275
+ return [ordered]@{
276
+ hookSpecificOutput = [ordered]@{
277
+ hookEventName = 'PreToolUse'
278
+ permissionDecision = 'deny'
279
+ permissionDecisionReason = 'PRD_FEATURE_BLOCKED: payload anomaly - ' +
280
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $envelope.Anomaly) +
281
+ '. The gate fails closed on an envelope it cannot read.'
282
+ }
283
+ }
170
284
  }
171
285
 
172
- $subagent = $toolInput.subagent_type
286
+ $subagent = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'subagent_type'
173
287
  if (-not $subagent -or $subagent -ne 'atomic-planner') {
174
288
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
175
289
  }
176
290
 
177
- $prompt = [string]$toolInput.prompt
291
+ $prompt = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'prompt'
178
292
  $folder = Find-PrdFeatureFolderFromPrompt -Prompt $prompt
179
293
  if (-not $folder) {
180
294
  $folder = Get-PrdFeatureCheckpointFolder
@@ -191,17 +305,36 @@ function Invoke-PrdFeatureBeforePlannerDecision {
191
305
  }
192
306
 
193
307
  $folderNormalized = ($folder -replace '\\', '/').TrimEnd('/')
194
- $missing = Get-PrdFeatureMissingFile -FeatureFolder $folderNormalized
308
+
309
+ # Derive the prerequisite set from the persisted work-mode marker rather
310
+ # than a fixed spec.md/user-story.md pair. A marker that cannot be read or
311
+ # recognized must fail closed to the strictest set, not fail open.
312
+ $issueContent = Get-PrdFeatureIssueContent -FeatureFolder $folderNormalized
313
+ $workMode = Resolve-PrdFeatureWorkMode -IssueContent $issueContent
314
+ $modeDetermined = [bool]$workMode
315
+ # Force array wrapping: PowerShell unravels a zero-element array return down
316
+ # the pipeline to $null, which would otherwise fail the Mandatory
317
+ # -RequiredFile parameter on Get-PrdFeatureMissingFile for minor-audit mode.
318
+ $required = @(Get-PrdFeatureRequiredFile -WorkMode $workMode)
319
+
320
+ $missing = Get-PrdFeatureMissingFile -FeatureFolder $folderNormalized -RequiredFile $required
195
321
  if ($missing.Count -eq 0) {
196
322
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
197
323
  }
198
324
 
199
325
  $list = ($missing -join ', ')
326
+ if ($modeDetermined) {
327
+ $reason = "PRD_FEATURE_BLOCKED: cannot delegate to atomic-planner before prd-feature outputs are present in '$folderNormalized'. Missing: $list (work mode: $workMode). Invoke the prd-feature subagent first."
328
+ }
329
+ else {
330
+ $reason = "PRD_FEATURE_BLOCKED: cannot delegate to atomic-planner before prd-feature outputs are present in '$folderNormalized'. Missing: $list. Work mode could not be determined from '$folderNormalized/issue.md' (marker absent, unreadable, or unrecognized); failing closed to the strictest prerequisite set (spec.md, user-story.md). Invoke the prd-feature subagent first."
331
+ }
332
+
200
333
  return [ordered]@{
201
334
  hookSpecificOutput = [ordered]@{
202
335
  hookEventName = 'PreToolUse'
203
336
  permissionDecision = 'deny'
204
- permissionDecisionReason = "PRD_FEATURE_BLOCKED: cannot delegate to atomic-planner before prd-feature outputs are present in '$folderNormalized'. Missing: $list. Invoke the prd-feature subagent first."
337
+ permissionDecisionReason = $reason
205
338
  }
206
339
  }
207
340
  }
@@ -211,13 +344,7 @@ if ($MyInvocation.InvocationName -eq '.') {
211
344
  return
212
345
  }
213
346
 
214
- try {
215
- $decision = Invoke-PrdFeatureBeforePlannerDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
216
- }
217
- catch {
218
- Write-Error $_
219
- exit 1
220
- }
347
+ $decision = Invoke-PrdFeatureBeforePlannerDecision -ToolInputRaw (Read-ClaudeHookRawPayload)
221
348
 
222
349
  $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
223
350