@danmoisan/drm-copilot-mcp 1.1.6 → 1.1.8

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 (56) hide show
  1. package/out/mcp-server.js +73 -1
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/atomic-planner.md +2 -0
  4. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +6 -5
  5. package/resources/claude-customizations/.claude/agents/parallel-planner.md +24 -7
  6. package/resources/claude-customizations/.claude/agents/prd-feature.md +5 -0
  7. package/resources/claude-customizations/.claude/agents/task-researcher.md +3 -0
  8. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 +8 -5
  9. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +186 -13
  10. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +186 -13
  11. package/resources/claude-customizations/.claude/hooks/persist-session-id.ps1 +16 -5
  12. package/resources/claude-customizations/.claude/hooks/validate-planner-output.ps1 +122 -0
  13. package/resources/claude-customizations/.claude/hooks/validate-prd-feature-output.ps1 +91 -0
  14. package/resources/claude-customizations/.claude/hooks/validate-task-researcher-output.ps1 +69 -1
  15. package/resources/claude-customizations/.claude/lib/bash/parallel-lane-assertion.sh +495 -0
  16. package/resources/claude-customizations/.claude/lib/bash/report-lane-assertion.sh +169 -0
  17. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +7 -5
  18. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +4 -2
  19. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +4 -2
  20. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +2 -0
  21. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 +5 -3
  22. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusTokenShape.psm1 +2 -0
  23. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +6 -4
  24. package/resources/claude-customizations/.claude/lib/codex-routing/CodexDeployment.psm1 +2 -0
  25. package/resources/claude-customizations/.claude/lib/codex-routing/CodexTopology.psm1 +2 -0
  26. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryValidation.psm1 +16 -16
  27. package/resources/claude-customizations/.claude/lib/hook-payload/HookPayload.psm1 +2 -0
  28. package/resources/claude-customizations/.claude/lib/mermaid/MermaidGrammar.psm1 +2 -0
  29. package/resources/claude-customizations/.claude/lib/mermaid/MermaidLineScanner.psm1 +3 -1
  30. package/resources/claude-customizations/.claude/lib/mermaid/MermaidMarkdownFences.psm1 +2 -0
  31. package/resources/claude-customizations/.claude/lib/mermaid/MermaidValidation.psm1 +5 -3
  32. package/resources/claude-customizations/.claude/lib/model-routing/ModelRouting.psm1 +2 -0
  33. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorState.psm1 +11 -0
  34. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCheckpointValue.psm1 +2 -0
  35. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCodexModelReceipts.psm1 +4 -2
  36. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCodexTopologyReceipts.psm1 +4 -2
  37. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 +9 -7
  38. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCompletionChecks.psm1 +4 -2
  39. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateModelReceipts.psm1 +4 -2
  40. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateReceipts.psm1 +3 -1
  41. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateRoutingContract.psm1 +4 -2
  42. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateRoutingMatrix.psm1 +3 -1
  43. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateUnconditional.psm1 +7 -5
  44. package/resources/claude-customizations/.claude/lib/requirements/GeneratedDocumentCounters.psm1 +44 -0
  45. package/resources/claude-customizations/.claude/settings.json +9 -0
  46. package/resources/claude-customizations/.claude/skills/acceptance-criteria-tracking/SKILL.md +2 -0
  47. package/resources/claude-customizations/.claude/skills/atomic-plan-contract/SKILL.md +4 -0
  48. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +4 -4
  49. package/resources/claude-customizations/.claude/skills/fill-feature-docs/SKILL.md +3 -0
  50. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +14 -9
  51. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +3 -3
  52. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +10 -3
  53. package/resources/claude-customizations/.claude/skills/remediation-handoff-atomic-planner/SKILL.md +2 -0
  54. package/resources/claude-customizations/.claude/skills/research-issue/SKILL.md +3 -0
  55. package/resources/claude-customizations/pack-manifests/core.json +4 -0
  56. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
@@ -13,6 +13,19 @@
13
13
  persisted under .claude/state/python-batch-budget.<session_id>.json. Only distinct
14
14
  file paths are counted; repeated edits to the same file consume one slot.
15
15
 
16
+ The session id is resolved from the first non-empty of: the CLAUDE_SESSION_ID
17
+ environment variable; the contents of <root>/.claude/state/current-session-id;
18
+ a worktree-derived identifier built from the root's leaf name and a short
19
+ stable hash of its normalized path. The resolved value is sanitized before it
20
+ is composed into a file name, so a hostile id cannot escape the state
21
+ directory. Resolving the id never creates the state directory.
22
+
23
+ Candidate paths are contained to the resolved root. A candidate that resolves
24
+ outside it is discarded: the decision is 'allow', no slot is consumed, and no
25
+ state is written. Persisted entries that fail the same containment test are
26
+ dropped when state is rehydrated, so a state file carried between worktrees
27
+ cannot spend this worktree's budget.
28
+
16
29
  Test files are those matching:
17
30
  - tests/**/*.py
18
31
  - test_*.py
@@ -36,6 +49,127 @@ param()
36
49
 
37
50
 
38
51
  Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
52
+
53
+ function Test-PythonBatchBudgetPathInRoot {
54
+ <#
55
+ .SYNOPSIS
56
+ Reports whether a candidate path belongs to the batch-budget root.
57
+ .DESCRIPTION
58
+ Compares forward-slash-normalized forms of the candidate and the root,
59
+ case-insensitively. A relative candidate carries no root of its own and is
60
+ admitted, which is what keeps a relative path recorded by one worktree from
61
+ being treated as foreign by another.
62
+ #>
63
+ [CmdletBinding()]
64
+ [OutputType([bool])]
65
+ param(
66
+ [AllowNull()]
67
+ [AllowEmptyString()]
68
+ [string] $Path,
69
+
70
+ [AllowNull()]
71
+ [AllowEmptyString()]
72
+ [string] $Root
73
+ )
74
+
75
+ if ([string]::IsNullOrWhiteSpace($Path)) {
76
+ return $false
77
+ }
78
+
79
+ $normalizedPath = $Path -replace '\\', '/'
80
+ if ($normalizedPath -notmatch '^(/|[A-Za-z]:/)') {
81
+ return $true
82
+ }
83
+
84
+ $normalizedRoot = ($Root -replace '\\', '/').TrimEnd('/')
85
+ if ([string]::IsNullOrWhiteSpace($normalizedRoot)) {
86
+ return $true
87
+ }
88
+
89
+ return ([string]::Equals($normalizedPath, $normalizedRoot, [System.StringComparison]::OrdinalIgnoreCase) -or $normalizedPath.StartsWith($normalizedRoot + '/', [System.StringComparison]::OrdinalIgnoreCase))
90
+ }
91
+
92
+ function ConvertTo-PythonBatchBudgetSafeSegment {
93
+ <#
94
+ .SYNOPSIS
95
+ Reduces a session id to characters that are safe in a file name.
96
+ #>
97
+ [CmdletBinding()]
98
+ [OutputType([string])]
99
+ param(
100
+ [AllowNull()]
101
+ [AllowEmptyString()]
102
+ [string] $Value
103
+ )
104
+
105
+ return ($Value -replace '[^A-Za-z0-9._-]', '_')
106
+ }
107
+
108
+ function Get-PythonBatchBudgetSessionId {
109
+ <#
110
+ .SYNOPSIS
111
+ Resolves the session id used to compose the batch-budget state-file name.
112
+ .DESCRIPTION
113
+ Returns the first non-empty of: the explicit SessionId argument; the
114
+ CLAUDE_SESSION_ID environment variable; the contents of the session-id state
115
+ file; a worktree-derived identifier. The result is sanitized so it cannot
116
+ escape the state directory. The session-id state file is read through the
117
+ ReadSessionIdFile seam and is never created, so resolution performs no write.
118
+ #>
119
+ [CmdletBinding()]
120
+ [OutputType([string])]
121
+ param(
122
+ [AllowNull()]
123
+ [AllowEmptyString()]
124
+ [string] $SessionId,
125
+
126
+ [AllowNull()]
127
+ [AllowEmptyString()]
128
+ [string] $Root,
129
+
130
+ [Parameter(Mandatory)]
131
+ [string] $SessionIdFilePath,
132
+
133
+ [scriptblock] $ReadSessionIdFile
134
+ )
135
+
136
+ $candidates = @(
137
+ $SessionId
138
+ $env:CLAUDE_SESSION_ID
139
+ )
140
+
141
+ foreach ($candidate in $candidates) {
142
+ if (-not [string]::IsNullOrWhiteSpace($candidate)) {
143
+ return (ConvertTo-PythonBatchBudgetSafeSegment -Value $candidate.Trim())
144
+ }
145
+ }
146
+
147
+ $fromFile = ''
148
+ try {
149
+ $fromFile = [string](& $ReadSessionIdFile $SessionIdFilePath)
150
+ } catch {
151
+ Write-Verbose "Ignoring unreadable session-id file '$SessionIdFilePath': $($_.Exception.Message)"
152
+ $fromFile = ''
153
+ }
154
+
155
+ if (-not [string]::IsNullOrWhiteSpace($fromFile)) {
156
+ return (ConvertTo-PythonBatchBudgetSafeSegment -Value $fromFile.Trim())
157
+ }
158
+
159
+ $normalizedRoot = ($Root -replace '\\', '/').TrimEnd('/')
160
+ $leaf = ConvertTo-PythonBatchBudgetSafeSegment -Value (Split-Path -Path $normalizedRoot -Leaf)
161
+
162
+ $sha = [System.Security.Cryptography.SHA256]::Create()
163
+ try {
164
+ $hashBytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($normalizedRoot))
165
+ } finally {
166
+ $sha.Dispose()
167
+ }
168
+ $shortHash = -join (@($hashBytes[0..3]) | ForEach-Object { $_.ToString('x2') })
169
+
170
+ return "worktree-$leaf-$shortHash"
171
+ }
172
+
39
173
  function Get-PythonBatchBudgetState {
40
174
  [CmdletBinding()]
41
175
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -66,14 +200,26 @@ function ConvertTo-PythonBatchBudgetState {
66
200
  [int] $ProdCap,
67
201
 
68
202
  [Parameter(Mandatory)]
69
- [int] $TestCap
203
+ [int] $TestCap,
204
+
205
+ [AllowNull()]
206
+ [AllowEmptyString()]
207
+ [string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent)
70
208
  )
71
209
 
72
210
  $state = Get-PythonBatchBudgetState -ProdCap $ProdCap -TestCap $TestCap
73
211
  if ($null -ne $InputObject.prodCap) { $state.prodCap = [int]$InputObject.prodCap }
74
212
  if ($null -ne $InputObject.testCap) { $state.testCap = [int]$InputObject.testCap }
75
- if ($null -ne $InputObject.prodFiles) { $state.prodFiles = @($InputObject.prodFiles) }
76
- if ($null -ne $InputObject.testFiles) { $state.testFiles = @($InputObject.testFiles) }
213
+
214
+ # Persisted entries that resolve outside this root belong to another worktree
215
+ # and are dropped, so a state file carried across worktrees cannot spend this
216
+ # worktree's budget.
217
+ if ($null -ne $InputObject.prodFiles) {
218
+ $state.prodFiles = @(@($InputObject.prodFiles) | Where-Object { Test-PythonBatchBudgetPathInRoot -Path $_ -Root $Root })
219
+ }
220
+ if ($null -ne $InputObject.testFiles) {
221
+ $state.testFiles = @(@($InputObject.testFiles) | Where-Object { Test-PythonBatchBudgetPathInRoot -Path $_ -Root $Root })
222
+ }
77
223
 
78
224
  return $state
79
225
  }
@@ -113,7 +259,11 @@ function Invoke-PythonBatchBudgetDecision {
113
259
  [System.Collections.IDictionary] $State,
114
260
 
115
261
  [Parameter(Mandatory)]
116
- [string] $StateFile
262
+ [string] $StateFile,
263
+
264
+ [AllowNull()]
265
+ [AllowEmptyString()]
266
+ [string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent)
117
267
  )
118
268
 
119
269
  $normalized = $FilePath -replace '\\', '/'
@@ -121,6 +271,13 @@ function Invoke-PythonBatchBudgetDecision {
121
271
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
122
272
  }
123
273
 
274
+ # An out-of-root candidate is discarded rather than denied: it consumes no
275
+ # slot and writes no state, so this hook stays deny-only for real overruns.
276
+ if (-not (Test-PythonBatchBudgetPathInRoot -Path $normalized -Root $Root)) {
277
+ Write-Verbose "Discarding batch-budget candidate '$normalized': it resolves outside the batch-budget root '$Root'."
278
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
279
+ }
280
+
124
281
  $isTestFile = ($normalized -match '(^|/)tests/.*\.py$') -or ($normalized -match '(^|/)test_[^/]+\.py$')
125
282
  $targetList = if ($isTestFile) { @($State.testFiles) } else { @($State.prodFiles) }
126
283
  $cap = if ($isTestFile) { [int]$State.testCap } else { [int]$State.prodCap }
@@ -151,10 +308,17 @@ function Invoke-PythonBatchBudgetHook {
151
308
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
152
309
  param(
153
310
  [string] $ToolInputRaw,
154
- [string] $SessionId = 'default',
155
- [string] $Root = (Get-Location).Path,
311
+ [string] $SessionId = '',
312
+ [string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent),
156
313
  [int] $ProdCap = 3,
157
314
  [int] $TestCap = 3,
315
+ [scriptblock] $ReadSessionIdFile = {
316
+ param([string] $Path)
317
+ if (Test-Path -LiteralPath $Path -PathType Leaf) {
318
+ return (Get-Content -LiteralPath $Path -Raw)
319
+ }
320
+ return ''
321
+ },
158
322
  [scriptblock] $TestPathExists = { param([string] $Path) Test-Path -Path $Path },
159
323
  [scriptblock] $EnsureDirectory = { param([string] $Path) New-Item -ItemType Directory -Path $Path -Force | Out-Null },
160
324
  [scriptblock] $ReadState = { param([string] $Path) Get-Content -Path $Path -Raw },
@@ -183,23 +347,32 @@ function Invoke-PythonBatchBudgetHook {
183
347
  }
184
348
 
185
349
  $stateDir = Join-Path -Path $Root -ChildPath '.claude/state'
350
+
351
+ # Resolved before the directory is ensured, because reading the session-id
352
+ # file must never be what creates the state directory.
353
+ $resolvedSessionId = Get-PythonBatchBudgetSessionId `
354
+ -SessionId $SessionId `
355
+ -Root $Root `
356
+ -SessionIdFilePath (Join-Path -Path $stateDir -ChildPath 'current-session-id') `
357
+ -ReadSessionIdFile $ReadSessionIdFile
358
+
186
359
  if (-not (& $TestPathExists $stateDir)) {
187
360
  & $EnsureDirectory $stateDir
188
361
  }
189
362
 
190
- $stateFile = Join-Path -Path $stateDir -ChildPath ("python-batch-budget.$SessionId.json")
363
+ $stateFile = Join-Path -Path $stateDir -ChildPath ("python-batch-budget.$resolvedSessionId.json")
191
364
  $state = Get-PythonBatchBudgetState -ProdCap $ProdCap -TestCap $TestCap
192
365
 
193
366
  if (& $TestPathExists $stateFile) {
194
367
  try {
195
368
  $loaded = & $ReadState $stateFile | ConvertFrom-Json -ErrorAction Stop
196
- $state = ConvertTo-PythonBatchBudgetState -InputObject $loaded -ProdCap $ProdCap -TestCap $TestCap
369
+ $state = ConvertTo-PythonBatchBudgetState -InputObject $loaded -ProdCap $ProdCap -TestCap $TestCap -Root $Root
197
370
  } catch {
198
371
  Write-Verbose "Ignoring unreadable Python batch-budget state file '$stateFile': $($_.Exception.Message)"
199
372
  }
200
373
  }
201
374
 
202
- $decision = Invoke-PythonBatchBudgetDecision -FilePath $filePath -State $state -StateFile $stateFile
375
+ $decision = Invoke-PythonBatchBudgetDecision -FilePath $filePath -State $state -StateFile $stateFile -Root $Root
203
376
  if ($decision.shouldWriteState) {
204
377
  try {
205
378
  & $WriteState $stateFile $decision.state
@@ -242,10 +415,10 @@ function Invoke-PythonBatchBudgetEntryPoint {
242
415
  $ToolInputRaw = [string](& $ReadPayload)
243
416
  }
244
417
 
245
- $sessionId = $env:CLAUDE_SESSION_ID
246
- if (-not $sessionId) {
247
- $sessionId = 'default'
248
- }
418
+ # No literal fallback here: an empty value routes through the hook's session
419
+ # resolution, which falls back to the session-id state file and then to a
420
+ # worktree-derived identifier.
421
+ $sessionId = [string]$env:CLAUDE_SESSION_ID
249
422
 
250
423
  $prodCap = 3
251
424
  $testCap = 3
@@ -8,13 +8,15 @@
8
8
  falling back to the CLAUDE_HOOK_INPUT environment variable (the existing
9
9
  SubagentStop-hook precedent), and extracts the 'session_id' field.
10
10
 
11
- Persistence channel:
12
- - When CLAUDE_ENV_FILE is set, appends the line
13
- 'CLAUDE_SESSION_ID=<id>' to that file. Variables persisted there are
11
+ Persistence channels:
12
+ - The id is always written to .claude/state/current-session-id, creating
13
+ that directory when absent. Publishing it unconditionally is what lets a
14
+ consumer that cannot read the env file, such as a hook running in a
15
+ fresh process, still resolve the session id from disk.
16
+ - When CLAUDE_ENV_FILE is set, the line 'CLAUDE_SESSION_ID=<id>' is
17
+ additionally appended to that file. Variables persisted there are
14
18
  exported to subsequent Bash tool commands in the session, which is how
15
19
  this hook provisions the otherwise-unset CLAUDE_SESSION_ID variable.
16
- - When CLAUDE_ENV_FILE is unset, writes the id to
17
- .claude/state/current-session-id instead.
18
20
 
19
21
  On malformed or empty input (missing/blank payload, unparseable JSON, or an
20
22
  absent/blank session_id) the hook performs no write. It always exits 0 so a
@@ -101,6 +103,15 @@ function Invoke-PersistSessionIdHook {
101
103
  switch ($decision.action) {
102
104
  'env-file' {
103
105
  & $AppendLine $decision.path ("CLAUDE_SESSION_ID={0}" -f $decision.sessionId)
106
+
107
+ # The state file is published unconditionally, so a consumer that
108
+ # cannot read the env file (a hook invoked in a fresh process, for
109
+ # example) still resolves the session id from disk.
110
+ $envStateDir = Split-Path -Path $StateFilePath -Parent
111
+ if ($envStateDir) {
112
+ & $EnsureDirectory $envStateDir
113
+ }
114
+ & $WriteStateFile $StateFilePath $decision.sessionId
104
115
  }
105
116
  'state-file' {
106
117
  $stateDir = Split-Path -Path $decision.path -Parent
@@ -110,6 +110,123 @@ function Test-HasPreflightSignal {
110
110
  )
111
111
  }
112
112
 
113
+ function Get-PlannerInternalReviewValidation {
114
+ [CmdletBinding()]
115
+ [OutputType([hashtable])]
116
+ param([Parameter(Mandatory = $true)][string] $AgentOutput)
117
+
118
+ $lines = @($AgentOutput -split "`r?`n")
119
+ $labelPattern = '^\s*(?:PLANNER-INTERNAL-REVIEW|CITATION-TO-TREE|AC-TRACEABILITY|SCOPE-BOUNDARY|CITATION|AC-INVENTORY|AC-MAPPING|UNRESOLVED-GAPS|PREFLIGHT)\s*:'
120
+ $headerPattern = '^\s*PLANNER-INTERNAL-REVIEW\s*:\s*(?<Value>.*?)\s*$'
121
+ $preflightPattern = '^\s*PREFLIGHT\s*:\s*(?:ALL CLEAR|REVISIONS REQUIRED)\s*$'
122
+ $labelIndexes = [System.Collections.Generic.List[int]]::new()
123
+ $headerIndexes = [System.Collections.Generic.List[int]]::new()
124
+ $preflightIndexes = [System.Collections.Generic.List[int]]::new()
125
+
126
+ for ($index = 0; $index -lt $lines.Count; $index++) {
127
+ if ($lines[$index] -match $labelPattern) {
128
+ $labelIndexes.Add($index)
129
+ }
130
+ if ($lines[$index] -match $headerPattern) {
131
+ $headerIndexes.Add($index)
132
+ }
133
+ if ($lines[$index] -match $preflightPattern) {
134
+ $preflightIndexes.Add($index)
135
+ }
136
+ }
137
+
138
+ if ($headerIndexes.Count -ne 1) {
139
+ return @{ Ok = $false; Message = 'planner internal review must contain exactly one `PLANNER-INTERNAL-REVIEW:` declaration.' }
140
+ }
141
+
142
+ $headerIndex = $headerIndexes[0]
143
+ $headerMatch = [regex]::Match($lines[$headerIndex], $headerPattern)
144
+ if ($headerMatch.Groups['Value'].Value.Trim() -ne 'PASS') {
145
+ return @{ Ok = $false; Message = 'planner internal review header must be exactly `PLANNER-INTERNAL-REVIEW: PASS`.' }
146
+ }
147
+
148
+ $preflightAfterHeader = @($preflightIndexes | Where-Object { $_ -gt $headerIndex })
149
+ if ($preflightAfterHeader.Count -ne 1) {
150
+ return @{ Ok = $false; Message = 'planner internal review must terminate at exactly one required `PREFLIGHT:` signal after its header.' }
151
+ }
152
+
153
+ $preflightIndex = $preflightAfterHeader[0]
154
+ foreach ($labelIndex in $labelIndexes) {
155
+ if ($labelIndex -lt $headerIndex -or $labelIndex -gt $preflightIndex) {
156
+ return @{ Ok = $false; Message = 'planner internal review declarations must not occur outside the bounded record.' }
157
+ }
158
+ }
159
+
160
+ $recordLines = @($lines[$headerIndex..$preflightIndex])
161
+ $requiredDeclarations = @('CITATION-TO-TREE', 'AC-TRACEABILITY', 'SCOPE-BOUNDARY')
162
+ foreach ($declaration in $requiredDeclarations) {
163
+ $declarationMatches = @($recordLines | Where-Object { $_ -match "^\s*$declaration\s*:" })
164
+ if ($declarationMatches.Count -ne 1) {
165
+ return @{ Ok = $false; Message = "planner internal review must contain exactly one `$declaration: PASS` declaration." }
166
+ }
167
+ if ($declarationMatches[0] -notmatch "^\s*$declaration\s*:\s*PASS\s*$") {
168
+ return @{ Ok = $false; Message = "planner internal review declaration `$declaration must be exactly PASS." }
169
+ }
170
+ }
171
+
172
+ $citationLines = @($recordLines | Where-Object { $_ -match '^\s*CITATION\s*:' })
173
+ if ($citationLines.Count -eq 0) {
174
+ return @{ Ok = $false; Message = 'planner internal review must contain at least one `CITATION:` record.' }
175
+ }
176
+ foreach ($citationLine in $citationLines) {
177
+ $citation = [regex]::Match($citationLine, '^\s*CITATION\s*:\s*(?<Path>[^|\s]+)\s*\|\s*(?<Locator>.+?\S)\s*$')
178
+ if (-not $citation.Success -or $citation.Groups['Path'].Value -notmatch '^(?![A-Za-z]:)(?!/)(?!\\)(?:\.?[^/\\|\s]+)(?:/[^/\\|\s]+)+$') {
179
+ return @{ Ok = $false; Message = 'planner internal review citations require a repository-relative path and nonblank locator.' }
180
+ }
181
+ }
182
+
183
+ $inventoryLines = @($recordLines | Where-Object { $_ -match '^\s*AC-INVENTORY\s*:' })
184
+ if ($inventoryLines.Count -ne 1) {
185
+ return @{ Ok = $false; Message = 'planner internal review must contain exactly one nonblank `AC-INVENTORY:` declaration.' }
186
+ }
187
+ $inventoryValue = ($inventoryLines[0] -replace '^\s*AC-INVENTORY\s*:\s*', '').Trim()
188
+ if ([string]::IsNullOrWhiteSpace($inventoryValue)) {
189
+ return @{ Ok = $false; Message = 'planner internal review `AC-INVENTORY:` must contain nonblank unique IDs.' }
190
+ }
191
+ $inventoryIds = @($inventoryValue -split ',' | ForEach-Object { $_.Trim() })
192
+ if ((@($inventoryIds | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count -gt 0) -or
193
+ (@($inventoryIds | Select-Object -Unique).Count -ne $inventoryIds.Count)) {
194
+ return @{ Ok = $false; Message = 'planner internal review `AC-INVENTORY:` must contain nonblank unique IDs.' }
195
+ }
196
+
197
+ $mappingLines = @($recordLines | Where-Object { $_ -match '^\s*AC-MAPPING\s*:' })
198
+ if ($mappingLines.Count -eq 0) {
199
+ return @{ Ok = $false; Message = 'planner internal review must contain one `AC-MAPPING:` record for every inventory ID.' }
200
+ }
201
+ $mappingIds = [System.Collections.Generic.List[string]]::new()
202
+ foreach ($mappingLine in $mappingLines) {
203
+ $mapping = [regex]::Match($mappingLine, '^\s*AC-MAPPING\s*:\s*(?<Id>[^|]*?)\s*\|\s*IMPLEMENTATION\s*:\s*(?<Implementation>[^|]*?)\s*\|\s*TESTS\s*:\s*(?<Tests>[^|]*?)\s*\|\s*EVIDENCE\s*:\s*(?<Evidence>.*?)\s*$')
204
+ if (-not $mapping.Success -or
205
+ [string]::IsNullOrWhiteSpace($mapping.Groups['Id'].Value) -or
206
+ [string]::IsNullOrWhiteSpace($mapping.Groups['Implementation'].Value) -or
207
+ [string]::IsNullOrWhiteSpace($mapping.Groups['Tests'].Value) -or
208
+ [string]::IsNullOrWhiteSpace($mapping.Groups['Evidence'].Value)) {
209
+ return @{ Ok = $false; Message = 'planner internal review `AC-MAPPING:` requires nonblank ID, IMPLEMENTATION, TESTS, and EVIDENCE fields.' }
210
+ }
211
+ $mappingIds.Add($mapping.Groups['Id'].Value.Trim())
212
+ }
213
+ if (@($mappingIds | Select-Object -Unique).Count -ne $mappingIds.Count) {
214
+ return @{ Ok = $false; Message = 'planner internal review `AC-MAPPING:` identifiers must be unique.' }
215
+ }
216
+ if ($inventoryIds.Count -ne $mappingIds.Count -or
217
+ (@($inventoryIds | Where-Object { $_ -notin $mappingIds }).Count -gt 0) -or
218
+ (@($mappingIds | Where-Object { $_ -notin $inventoryIds }).Count -gt 0)) {
219
+ return @{ Ok = $false; Message = 'planner internal review AC inventory and mapping identifiers must match exactly.' }
220
+ }
221
+
222
+ $gapLines = @($recordLines | Where-Object { $_ -match '^\s*UNRESOLVED-GAPS\s*:' })
223
+ if ($gapLines.Count -ne 1 -or $gapLines[0] -notmatch '^\s*UNRESOLVED-GAPS\s*:\s*NONE\s*$') {
224
+ return @{ Ok = $false; Message = 'planner internal review must contain exactly one `UNRESOLVED-GAPS: NONE` declaration.' }
225
+ }
226
+
227
+ return @{ Ok = $true; Message = $null }
228
+ }
229
+
113
230
  function Get-PlanStructureValidationReport {
114
231
  [CmdletBinding()]
115
232
  [OutputType([string[]])]
@@ -272,6 +389,11 @@ function Invoke-PlannerOutputValidation {
272
389
  return @{ Ok = $false; Message = $message }
273
390
  }
274
391
 
392
+ $review = Get-PlannerInternalReviewValidation -AgentOutput $agentOutput
393
+ if (-not $review.Ok) {
394
+ return @{ Ok = $false; Message = "atomic-planner hook: $($review.Message)" }
395
+ }
396
+
275
397
  return @{ Ok = $true; Message = $null }
276
398
  }
277
399
 
@@ -0,0 +1,91 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Validates numeric acceptance criteria produced by the prd-feature worker.
4
+ #>
5
+
6
+ [CmdletBinding()]
7
+ param()
8
+
9
+ Set-StrictMode -Version Latest
10
+ $ErrorActionPreference = 'Stop'
11
+
12
+ function Get-ArtifactPathFromOutput {
13
+ [CmdletBinding()]
14
+ [OutputType([string])]
15
+ param([Parameter(Mandatory = $true)][string] $Output, [Parameter(Mandatory = $true)][string] $Label)
16
+
17
+ $match = [regex]::Match($Output, "(?im)^\s*$([regex]::Escape($Label))\s*:\s*(?<path>\S+)")
18
+ if ($match.Success) { return $match.Groups['path'].Value }
19
+ return $null
20
+ }
21
+
22
+ function Test-NumericDerivationEvidence {
23
+ [CmdletBinding()]
24
+ [OutputType([hashtable])]
25
+ param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Content)
26
+
27
+ $section = [regex]::Match($Content, '(?ims)^##\s+Numeric\s+Derivation\s+Evidence\s*$.*?(?=^##\s|\z)')
28
+ if (-not $section.Success) { return @{ Ok = $false; Message = 'prd-feature hook: numeric criterion requires ## Numeric Derivation Evidence.' } }
29
+ $requiredLabels = @(
30
+ 'Complete Family', 'Exhaustive Search Scope', 'Inclusion Rules', 'Exclusion Rules',
31
+ 'Primary Search Strategy or Query Expression', 'Primary Member Set', 'Primary Count',
32
+ 'Cross-check Search Strategy or Query Expression', 'Cross-check Member Set', 'Cross-check Count',
33
+ 'Member-set Comparison'
34
+ )
35
+ $values = @{}
36
+ foreach ($label in $requiredLabels) {
37
+ $match = [regex]::Match($section.Value, "(?im)^[\t ]*[-*]?[\t ]*$([regex]::Escape($label))[\t ]*:[\t ]*(?<value>\S(?:.*\S)?)[\t ]*$")
38
+ if (-not $match.Success) { return @{ Ok = $false; Message = "prd-feature hook: numeric derivation evidence is missing $label." } }
39
+ $values[$label] = $match.Groups['value'].Value.Trim()
40
+ }
41
+ if ($values['Exhaustive Search Scope'] -notmatch '(?i)\b(entire|all|complete)\b.*\b(repository|repo|source tree|tree)\b') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation evidence does not declare an exhaustive repository search scope.' } }
42
+ $primaryStrategy = $values['Primary Search Strategy or Query Expression']
43
+ $crossCheckStrategy = $values['Cross-check Search Strategy or Query Expression']
44
+ if ($primaryStrategy -match '(?i)\b(single|narrow|named[- ]?pattern)\b' -or $crossCheckStrategy -match '(?i)\b(single|narrow|named[- ]?pattern)\b') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation evidence uses a narrow named-pattern search.' } }
45
+ if ([regex]::Replace($primaryStrategy, '\s+', '').ToLowerInvariant() -eq [regex]::Replace($crossCheckStrategy, '\s+', '').ToLowerInvariant()) { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation cross-check repeats the primary search strategy or query expression.' } }
46
+ $familyMembers = @($values['Complete Family'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
47
+ foreach ($familyMember in $familyMembers) {
48
+ if ($primaryStrategy -notmatch [regex]::Escape($familyMember) -or $crossCheckStrategy -notmatch [regex]::Escape($familyMember)) { return @{ Ok = $false; Message = "prd-feature hook: numeric derivation search does not cover complete family member '$familyMember'." } }
49
+ }
50
+ if ($values['Primary Count'] -notmatch '^\d+$' -or $values['Cross-check Count'] -notmatch '^\d+$') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation counts must be numeric.' } }
51
+ $primaryMembers = @($values['Primary Member Set'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
52
+ $crossCheckMembers = @($values['Cross-check Member Set'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
53
+ if ([int]$values['Primary Count'] -ne $primaryMembers.Count -or [int]$values['Cross-check Count'] -ne $crossCheckMembers.Count) { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation count does not match its independently enumerated member set.' } }
54
+ $normalizedPrimaryMembers = @($primaryMembers | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) -join '|'
55
+ $normalizedCrossCheckMembers = @($crossCheckMembers | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) -join '|'
56
+ if ($normalizedPrimaryMembers -ne $normalizedCrossCheckMembers) { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation primary and cross-check member sets disagree.' } }
57
+ if ($values['Member-set Comparison'] -notmatch '(?i)\b(equal|match|identical)\b') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation evidence is missing an explicit member-set comparison.' } }
58
+ return @{ Ok = $true; Message = $null }
59
+ }
60
+
61
+ function Test-SpecNumericCriterion {
62
+ [CmdletBinding()]
63
+ [OutputType([bool])]
64
+ param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $SpecContent)
65
+
66
+ $section = [regex]::Match($SpecContent, '(?ims)^##\s+Acceptance\s+Criteria\s*$.*?(?=^##\s|\z)')
67
+ return $section.Success -and [regex]::IsMatch($section.Value, '(?m)^\s*[-*]\s*\[.\].*\b\d+\b')
68
+ }
69
+
70
+ function Invoke-PrdFeatureOutputValidation {
71
+ [CmdletBinding()]
72
+ [OutputType([hashtable])]
73
+ param([string] $RawPayload)
74
+
75
+ if ([string]::IsNullOrWhiteSpace($RawPayload)) { return @{ Ok = $false; Message = 'prd-feature hook: CLAUDE_HOOK_INPUT is empty.' } }
76
+ try { $payload = $RawPayload | ConvertFrom-Json -ErrorAction Stop } catch { return @{ Ok = $false; Message = "prd-feature hook: failed to parse CLAUDE_HOOK_INPUT as JSON: $($_.Exception.Message)" } }
77
+ $output = if ($null -ne $payload.PSObject.Properties['output']) { $payload.output } else { $null }
78
+ if ([string]::IsNullOrWhiteSpace($output)) { return @{ Ok = $false; Message = 'prd-feature hook: agent output is empty.' } }
79
+ $specPath = Get-ArtifactPathFromOutput -Output $output -Label 'spec-path'
80
+ if ([string]::IsNullOrWhiteSpace($specPath) -or -not (Test-Path -LiteralPath $specPath -PathType Leaf)) { return @{ Ok = $false; Message = 'prd-feature hook: spec-path is required and must exist.' } }
81
+ $specContent = Get-Content -LiteralPath $specPath -Raw -ErrorAction Stop
82
+ if (-not (Test-SpecNumericCriterion -SpecContent $specContent)) { return @{ Ok = $true; Message = $null } }
83
+ $researchPath = Get-ArtifactPathFromOutput -Output $output -Label 'research-path'
84
+ if ([string]::IsNullOrWhiteSpace($researchPath) -or -not (Test-Path -LiteralPath $researchPath -PathType Leaf)) { return @{ Ok = $false; Message = 'prd-feature hook: numeric acceptance criterion requires an existing research-path.' } }
85
+ return Test-NumericDerivationEvidence -Content (Get-Content -LiteralPath $researchPath -Raw -ErrorAction Stop)
86
+ }
87
+
88
+ if ($MyInvocation.InvocationName -eq '.') { return }
89
+ $result = Invoke-PrdFeatureOutputValidation -RawPayload $env:CLAUDE_HOOK_INPUT
90
+ if (-not $result.Ok) { Write-Error $result.Message; exit 1 }
91
+ exit 0
@@ -161,6 +161,68 @@ function Test-AutomationFeasibilitySection {
161
161
  return @{ Ok = $true; Message = $null }
162
162
  }
163
163
 
164
+ function Test-NumericDerivationEvidence {
165
+ [CmdletBinding()]
166
+ [OutputType([hashtable])]
167
+ param(
168
+ [Parameter(Mandatory = $true)]
169
+ [AllowEmptyString()]
170
+ [string] $Content
171
+ )
172
+
173
+ $numericClaimPattern = '(?im)^\s*[-*]\s*Numeric\s+spec\.md\s+acceptance\s+criterion:\s*.*\b\d+\b'
174
+ if (-not [regex]::IsMatch($Content, $numericClaimPattern)) {
175
+ return @{ Ok = $true; Message = $null }
176
+ }
177
+
178
+ $section = [regex]::Match($Content, '(?ims)^##\s+Numeric\s+Derivation\s+Evidence\s*$.*?(?=^##\s|\z)')
179
+ if (-not $section.Success) {
180
+ return @{ Ok = $false; Message = 'task-researcher hook: numeric spec.md acceptance criterion is missing ## Numeric Derivation Evidence.' }
181
+ }
182
+
183
+ $requiredLabels = @(
184
+ 'Complete Family', 'Exhaustive Search Scope', 'Inclusion Rules', 'Exclusion Rules',
185
+ 'Primary Search Strategy or Query Expression', 'Primary Member Set', 'Primary Count',
186
+ 'Cross-check Search Strategy or Query Expression', 'Cross-check Member Set', 'Cross-check Count',
187
+ 'Member-set Comparison'
188
+ )
189
+ $values = @{}
190
+ foreach ($label in $requiredLabels) {
191
+ $match = [regex]::Match($section.Value, "(?im)^[\t ]*[-*]?[\t ]*$([regex]::Escape($label))[\t ]*:[\t ]*(?<value>\S(?:.*\S)?)[\t ]*$")
192
+ if (-not $match.Success) { return @{ Ok = $false; Message = "task-researcher hook: numeric derivation evidence is missing $label." } }
193
+ $values[$label] = $match.Groups['value'].Value.Trim()
194
+ }
195
+ if ($values['Exhaustive Search Scope'] -notmatch '(?i)\b(entire|all|complete)\b.*\b(repository|repo|source tree|tree)\b') {
196
+ return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation evidence does not declare an exhaustive repository search scope.' }
197
+ }
198
+ $primaryStrategy = $values['Primary Search Strategy or Query Expression']
199
+ $crossCheckStrategy = $values['Cross-check Search Strategy or Query Expression']
200
+ if ($primaryStrategy -match '(?i)\b(single|narrow|named[- ]?pattern)\b' -or $crossCheckStrategy -match '(?i)\b(single|narrow|named[- ]?pattern)\b') {
201
+ return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation evidence uses a narrow named-pattern search.' }
202
+ }
203
+ if ([regex]::Replace($primaryStrategy, '\s+', '').ToLowerInvariant() -eq [regex]::Replace($crossCheckStrategy, '\s+', '').ToLowerInvariant()) {
204
+ return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation cross-check repeats the primary search strategy or query expression.' }
205
+ }
206
+ $familyMembers = @($values['Complete Family'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
207
+ foreach ($familyMember in $familyMembers) {
208
+ if ($primaryStrategy -notmatch [regex]::Escape($familyMember) -or $crossCheckStrategy -notmatch [regex]::Escape($familyMember)) {
209
+ return @{ Ok = $false; Message = "task-researcher hook: numeric derivation search does not cover complete family member '$familyMember'." }
210
+ }
211
+ }
212
+ if ($values['Primary Count'] -notmatch '^\d+$' -or $values['Cross-check Count'] -notmatch '^\d+$') { return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation counts must be numeric.' } }
213
+ $primaryMembers = @($values['Primary Member Set'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
214
+ $crossCheckMembers = @($values['Cross-check Member Set'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
215
+ if ([int]$values['Primary Count'] -ne $primaryMembers.Count -or [int]$values['Cross-check Count'] -ne $crossCheckMembers.Count) {
216
+ return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation count does not match its independently enumerated member set.' }
217
+ }
218
+ $normalizedPrimaryMembers = @($primaryMembers | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) -join '|'
219
+ $normalizedCrossCheckMembers = @($crossCheckMembers | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) -join '|'
220
+ if ($normalizedPrimaryMembers -ne $normalizedCrossCheckMembers) { return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation primary and cross-check member sets disagree.' } }
221
+ if ($values['Member-set Comparison'] -notmatch '(?i)\b(equal|match|identical)\b') { return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation evidence is missing an explicit member-set comparison.' } }
222
+
223
+ return @{ Ok = $true; Message = $null }
224
+ }
225
+
164
226
  function Invoke-TaskResearcherOutputValidation {
165
227
  [CmdletBinding()]
166
228
  [OutputType([hashtable])]
@@ -209,6 +271,13 @@ function Invoke-TaskResearcherOutputValidation {
209
271
  return @{ Ok = $false; Message = $feasibilityResult.Message }
210
272
  }
211
273
 
274
+ if (Test-Path -LiteralPath $researchPath -PathType Leaf) {
275
+ $numericEvidenceResult = Test-NumericDerivationEvidence -Content (Get-Content -LiteralPath $researchPath -Raw -ErrorAction Stop)
276
+ if (-not $numericEvidenceResult.Ok) {
277
+ return $numericEvidenceResult
278
+ }
279
+ }
280
+
212
281
  return @{ Ok = $true; Message = $null }
213
282
  }
214
283
 
@@ -223,4 +292,3 @@ if (-not $result.Ok) {
223
292
  }
224
293
 
225
294
  exit 0
226
-