@danmoisan/drm-copilot-mcp 1.1.7 → 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 (44) hide show
  1. package/out/mcp-server.js +73 -1
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +6 -5
  4. package/resources/claude-customizations/.claude/agents/parallel-planner.md +24 -7
  5. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 +8 -5
  6. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +186 -13
  7. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +186 -13
  8. package/resources/claude-customizations/.claude/hooks/persist-session-id.ps1 +16 -5
  9. package/resources/claude-customizations/.claude/lib/bash/parallel-lane-assertion.sh +495 -0
  10. package/resources/claude-customizations/.claude/lib/bash/report-lane-assertion.sh +169 -0
  11. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +7 -5
  12. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +4 -2
  13. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +4 -2
  14. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +2 -0
  15. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 +5 -3
  16. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusTokenShape.psm1 +2 -0
  17. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +6 -4
  18. package/resources/claude-customizations/.claude/lib/codex-routing/CodexDeployment.psm1 +2 -0
  19. package/resources/claude-customizations/.claude/lib/codex-routing/CodexTopology.psm1 +2 -0
  20. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryValidation.psm1 +16 -16
  21. package/resources/claude-customizations/.claude/lib/hook-payload/HookPayload.psm1 +2 -0
  22. package/resources/claude-customizations/.claude/lib/mermaid/MermaidGrammar.psm1 +2 -0
  23. package/resources/claude-customizations/.claude/lib/mermaid/MermaidLineScanner.psm1 +3 -1
  24. package/resources/claude-customizations/.claude/lib/mermaid/MermaidMarkdownFences.psm1 +2 -0
  25. package/resources/claude-customizations/.claude/lib/mermaid/MermaidValidation.psm1 +5 -3
  26. package/resources/claude-customizations/.claude/lib/model-routing/ModelRouting.psm1 +2 -0
  27. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorState.psm1 +11 -0
  28. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCheckpointValue.psm1 +2 -0
  29. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCodexModelReceipts.psm1 +4 -2
  30. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCodexTopologyReceipts.psm1 +4 -2
  31. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 +9 -7
  32. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCompletionChecks.psm1 +4 -2
  33. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateModelReceipts.psm1 +4 -2
  34. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateReceipts.psm1 +3 -1
  35. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateRoutingContract.psm1 +4 -2
  36. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateRoutingMatrix.psm1 +3 -1
  37. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateUnconditional.psm1 +7 -5
  38. package/resources/claude-customizations/.claude/lib/requirements/GeneratedDocumentCounters.psm1 +12 -0
  39. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +4 -4
  40. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +12 -9
  41. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +3 -3
  42. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +8 -3
  43. package/resources/claude-customizations/pack-manifests/core.json +2 -0
  44. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
package/out/mcp-server.js CHANGED
@@ -19289,6 +19289,65 @@ var BlastRadiusDeriveFileSystem = class {
19289
19289
  }
19290
19290
  };
19291
19291
 
19292
+ // ../../extensions/drm-copilot/src/lib/push-down/claude-gitignore-merge.ts
19293
+ var CLAUDE_GITIGNORE_RELATIVE_PATH = ".gitignore";
19294
+ var CLAUDE_GITIGNORE_BEGIN_SENTINEL = "# BEGIN drm-copilot managed ignores";
19295
+ var CLAUDE_GITIGNORE_END_SENTINEL = "# END drm-copilot managed ignores";
19296
+ var CLAUDE_MANAGED_IGNORE_ENTRIES = [
19297
+ ".claude/state/",
19298
+ ".codex/state/"
19299
+ ];
19300
+ var LINE_SEPARATOR = "\n";
19301
+ function normalizeLineEndings(text) {
19302
+ return text.replace(/\r\n?/g, LINE_SEPARATOR);
19303
+ }
19304
+ function toLines(normalized) {
19305
+ if (normalized === "") {
19306
+ return [];
19307
+ }
19308
+ const lines = normalized.split(LINE_SEPARATOR);
19309
+ if (lines[lines.length - 1] === "") {
19310
+ lines.pop();
19311
+ }
19312
+ return lines;
19313
+ }
19314
+ function renderManagedBlock() {
19315
+ return [
19316
+ CLAUDE_GITIGNORE_BEGIN_SENTINEL,
19317
+ ...CLAUDE_MANAGED_IGNORE_ENTRIES,
19318
+ CLAUDE_GITIGNORE_END_SENTINEL
19319
+ ];
19320
+ }
19321
+ function mergeClaudeGitignore(currentText) {
19322
+ const lines = toLines(normalizeLineEndings(currentText));
19323
+ const managedBlock = renderManagedBlock();
19324
+ const beginIndex = lines.indexOf(CLAUDE_GITIGNORE_BEGIN_SENTINEL);
19325
+ if (beginIndex === -1) {
19326
+ return appendManagedBlock(lines, managedBlock);
19327
+ }
19328
+ const endOffset = lines.slice(beginIndex).indexOf(CLAUDE_GITIGNORE_END_SENTINEL);
19329
+ const endIndex = endOffset === -1 ? beginIndex : beginIndex + endOffset;
19330
+ const merged = [
19331
+ ...lines.slice(0, beginIndex),
19332
+ ...managedBlock,
19333
+ ...lines.slice(endIndex + 1)
19334
+ ];
19335
+ return toDocument(merged);
19336
+ }
19337
+ function appendManagedBlock(lines, managedBlock) {
19338
+ const trailingBlankRemoved = [...lines];
19339
+ while (trailingBlankRemoved.length > 0 && trailingBlankRemoved[trailingBlankRemoved.length - 1] === "") {
19340
+ trailingBlankRemoved.pop();
19341
+ }
19342
+ if (trailingBlankRemoved.length === 0) {
19343
+ return toDocument(managedBlock);
19344
+ }
19345
+ return toDocument([...trailingBlankRemoved, "", ...managedBlock]);
19346
+ }
19347
+ function toDocument(lines) {
19348
+ return `${lines.join(LINE_SEPARATOR)}${LINE_SEPARATOR}`;
19349
+ }
19350
+
19292
19351
  // ../../extensions/drm-copilot/src/lib/push-down/claude-customizations.ts
19293
19352
  var ARTIFACT_DIRECTORY4 = "artifacts/claude-customizations";
19294
19353
  var ROOT_FOLDERS2 = [".claude", "config"];
@@ -19365,7 +19424,7 @@ function pushDownCustomizations4(options) {
19365
19424
  variantRoot: effectiveBundle
19366
19425
  }
19367
19426
  );
19368
- return pushDownCustomizations({
19427
+ const summary = pushDownCustomizations({
19369
19428
  repoRoot,
19370
19429
  destinationRoot,
19371
19430
  fs: excludingFs,
@@ -19376,6 +19435,19 @@ function pushDownCustomizations4(options) {
19376
19435
  rewriteReferences: passthroughRewrite2,
19377
19436
  ...clock === void 0 ? {} : { clock }
19378
19437
  });
19438
+ deliverDestinationGitignore(fs10, destinationRoot);
19439
+ return summary;
19440
+ }
19441
+ function deliverDestinationGitignore(fs10, destinationRoot) {
19442
+ const destinationPath = joinPosix7(
19443
+ destinationRoot,
19444
+ CLAUDE_GITIGNORE_RELATIVE_PATH
19445
+ );
19446
+ const currentText = fs10.isFile(destinationPath) ? fs10.readTextFile(destinationPath) : "";
19447
+ const mergedText = mergeClaudeGitignore(currentText);
19448
+ if (mergedText !== currentText) {
19449
+ fs10.writeTextFile(destinationPath, mergedText);
19450
+ }
19379
19451
  }
19380
19452
 
19381
19453
  // ../../extensions/drm-copilot/src/lib/push-down/push-down-service-call.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmoisan/drm-copilot-mcp",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
4
4
  "description": "Stdio MCP server exposing drm-copilot repo-automation tools.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -89,11 +89,12 @@ reference; it is not invoked on the destination-runtime path. Cohort recoloring
89
89
  batching use `compute-cohorts.sh` and `compute-concurrency-batches.sh` under the same allowlist
90
90
  entry.
91
91
 
92
- The two `poetry run` grants remain for the repository-local paths that still need an interpreter:
93
- the checkpoint-validator CLI fallback the skill names in its `## Parallel-Level Checkpoint` section
94
- is invoked as `poetry run python -m`, and the drift-detection CLI likewise. Both grants stay scoped
95
- to those two invocation forms only — not to `poetry run` as a whole so `pytest`, `black`, `ruff`,
96
- and every other `poetry run` subcommand remain outside the allowlist. The sibling persona
92
+ The `poetry run` grants remain for the repository-local paths that still need an interpreter. The
93
+ skill's `## Parallel-Level Checkpoint` section now validates through
94
+ `mcp__drm-copilot__validate_orchestration_artifacts`, so exactly one named consumer is left: the
95
+ drift-detection CLI, invoked as `poetry run python -m`. Each grant stays scoped to its own
96
+ `poetry run python` invocation form — not to `poetry run` as a whole so `pytest`, `black`,
97
+ `ruff`, and every other `poetry run` subcommand remain outside the allowlist. The sibling persona
97
98
  `.claude/agents/parallel-planner.md` records the same destination-runtime posture.
98
99
 
99
100
  ## Startup Protocol
@@ -17,6 +17,7 @@ tools:
17
17
  - "Bash(bash .claude/lib/bash/compute-cohorts.sh*)"
18
18
  - "Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"
19
19
  - "Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"
20
+ - "Bash(bash .claude/lib/bash/report-lane-assertion.sh*)"
20
21
  - "mcp__drm-copilot__validate_orchestration_artifacts"
21
22
  skills:
22
23
  - policy-compliance-order
@@ -148,19 +149,24 @@ plan a parallel run.
148
149
  relation come from `.claude/lib/blast-radius/BlastRadius.psm1`:
149
150
 
150
151
  ```powershell
151
- Import-Module .claude/lib/blast-radius/BlastRadius.psm1 -Force
152
+ $repoRoot = git rev-parse --show-toplevel
153
+ Import-Module (Join-Path $repoRoot '.claude/lib/blast-radius/BlastRadius.psm1') -Force -ErrorAction Stop
152
154
  ```
153
155
 
156
+ The default PowerShell 5.1 execution policy blocks `Import-Module` of a `.psm1` file, so `pwsh` is
157
+ mandatory here.
158
+
154
159
  The facade exports `Get-PlanPaths`, `Get-BlastRadius`, `Get-BlastRadiusFromObservedPaths`,
155
160
  `Test-BlastRadius`, and `Test-BlastRadiusConflict`. Its truth table is
156
161
  `config/blast-radius.json`, which push-down publishes alongside `.claude`.
157
162
 
158
163
  **Cohort seeding and concurrency batching — bash entry points.** The bash library is granted as
159
- three entry-point-specific allowlist entries — `"Bash(bash .claude/lib/bash/compute-cohorts.sh*)"`,
160
- `"Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"`, and
161
- `"Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"` one per command-line entry point.
162
- The six sourceable libraries carry no grant because they are never invoked directly. The two
163
- commands below require the first two of those entries:
164
+ four entry-point-specific allowlist entries — `"Bash(bash .claude/lib/bash/compute-cohorts.sh*)"`,
165
+ `"Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"`,
166
+ `"Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"`, and the entry for
167
+ `.claude/lib/bash/report-lane-assertion.sh` declared in this persona's `tools:` list one per
168
+ command-line entry point. The seven sourceable libraries carry no grant because they are never
169
+ invoked directly. The two commands below require the first two of those entries:
164
170
 
165
171
  ```bash
166
172
  bash .claude/lib/bash/compute-cohorts.sh --keys "<k1> <k2> ..." --edges "<a>:<b> ..."
@@ -176,6 +182,16 @@ bash .claude/lib/bash/validate-parallel-manifest.sh --print-mode <manifest-path>
176
182
  bash .claude/lib/bash/validate-parallel-manifest.sh --print-max-concurrency <manifest-path>
177
183
  ```
178
184
 
185
+ **Lane assertion — bash entry point.** The entry-point grant for
186
+ `.claude/lib/bash/report-lane-assertion.sh` covers:
187
+
188
+ ```bash
189
+ bash .claude/lib/bash/report-lane-assertion.sh --manifest <manifest-path> --edges "<a>:<b> ..."
190
+ ```
191
+
192
+ The diagnostic is advisory only. It never blocks the run, never modifies or suppresses a derived
193
+ edge, never feeds cohort computation, and always exits 0, including when it reports disagreements.
194
+
179
195
  **Python modules are the repository authority, not the runtime path.**
180
196
  `scripts/dev_tools/compute_blast_radius.py`, `scripts/dev_tools/parallel_cohort_computation.py`,
181
197
  and `scripts/dev_tools/parallel_manifest_contract.py` remain the reference implementations that the
@@ -183,4 +199,5 @@ ported libraries are asserted against by shared fixture corpora. Do not invoke t
183
199
  destination-runtime path; cite them for their contract.
184
200
 
185
201
  The `"Bash(poetry run *)"` allowlist entry is retained for the repository-local paths that still
186
- need it it is not required by any step above.
202
+ need a Python interpreter. The four bash entry points above and the PowerShell blast-radius facade
203
+ run without it, so no destination-runtime step depends on that grant.
@@ -267,7 +267,7 @@ function Find-OrchestrationDelegationIssueNumber {
267
267
 
268
268
  if (-not $Prompt) { return $null }
269
269
 
270
- $keyed = [regex]::Match($Prompt, 'issue[_-]?num(?:ber)?\s*[:=]\s*#?(\d+)', 'IgnoreCase')
270
+ $keyed = [regex]::Match($Prompt, 'issue(?:[_-]?num(?:ber)?|\s+number)\s*[:=]\s*#?(\d+)', 'IgnoreCase')
271
271
  if ($keyed.Success) { return $keyed.Groups[1].Value }
272
272
  $hashForm = [regex]::Match($Prompt, '(?:^|\s)#(\d+)\b')
273
273
  if ($hashForm.Success) { return $hashForm.Groups[1].Value }
@@ -344,14 +344,17 @@ function Find-OrchestrationModeRecord {
344
344
  )
345
345
 
346
346
  if ($null -eq $Records) { return $null }
347
- foreach ($record in @($Records)) {
348
- if ($null -eq $record) { continue }
349
- if ($TargetFolder) {
347
+ if ($TargetFolder) {
348
+ foreach ($record in @($Records)) {
349
+ if ($null -eq $record) { continue }
350
350
  $folder = Get-OrchestrationModeString -Value $record -Name 'feature_folder'
351
351
  $basename = Get-OrchestrationModeFolderBasename -Path $folder
352
352
  if ($basename -and $basename -eq $TargetFolder) { return $record }
353
353
  }
354
- if ($IssueNumber) {
354
+ }
355
+ if ($IssueNumber) {
356
+ foreach ($record in @($Records)) {
357
+ if ($null -eq $record) { continue }
355
358
  $issue = Get-OrchestrationModeString -Value $record -Name 'issue_num'
356
359
  if ($issue -and $issue -eq $IssueNumber) { return $record }
357
360
  }
@@ -14,6 +14,19 @@
14
14
  persisted under .claude/state/powershell-batch-budget.<session_id>.json. Only
15
15
  distinct file paths are counted; repeated edits to the same file consume one slot.
16
16
 
17
+ The session id is resolved from the first non-empty of: the CLAUDE_SESSION_ID
18
+ environment variable; the contents of <root>/.claude/state/current-session-id;
19
+ a worktree-derived identifier built from the root's leaf name and a short
20
+ stable hash of its normalized path. The resolved value is sanitized before it
21
+ is composed into a file name, so a hostile id cannot escape the state
22
+ directory. Resolving the id never creates the state directory.
23
+
24
+ Candidate paths are contained to the resolved root. A candidate that resolves
25
+ outside it is discarded: the decision is 'allow', no slot is consumed, and no
26
+ state is written. Persisted entries that fail the same containment test are
27
+ dropped when state is rehydrated, so a state file carried between worktrees
28
+ cannot spend this worktree's budget.
29
+
17
30
  Test files are those matching:
18
31
  - tests/**/*.ps1
19
32
  - *.Tests.ps1
@@ -39,6 +52,127 @@ param()
39
52
 
40
53
 
41
54
  Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
55
+
56
+ function Test-PowerShellBatchBudgetPathInRoot {
57
+ <#
58
+ .SYNOPSIS
59
+ Reports whether a candidate path belongs to the batch-budget root.
60
+ .DESCRIPTION
61
+ Compares forward-slash-normalized forms of the candidate and the root,
62
+ case-insensitively. A relative candidate carries no root of its own and is
63
+ admitted, which is what keeps a relative path recorded by one worktree from
64
+ being treated as foreign by another.
65
+ #>
66
+ [CmdletBinding()]
67
+ [OutputType([bool])]
68
+ param(
69
+ [AllowNull()]
70
+ [AllowEmptyString()]
71
+ [string] $Path,
72
+
73
+ [AllowNull()]
74
+ [AllowEmptyString()]
75
+ [string] $Root
76
+ )
77
+
78
+ if ([string]::IsNullOrWhiteSpace($Path)) {
79
+ return $false
80
+ }
81
+
82
+ $normalizedPath = $Path -replace '\\', '/'
83
+ if ($normalizedPath -notmatch '^(/|[A-Za-z]:/)') {
84
+ return $true
85
+ }
86
+
87
+ $normalizedRoot = ($Root -replace '\\', '/').TrimEnd('/')
88
+ if ([string]::IsNullOrWhiteSpace($normalizedRoot)) {
89
+ return $true
90
+ }
91
+
92
+ return ([string]::Equals($normalizedPath, $normalizedRoot, [System.StringComparison]::OrdinalIgnoreCase) -or $normalizedPath.StartsWith($normalizedRoot + '/', [System.StringComparison]::OrdinalIgnoreCase))
93
+ }
94
+
95
+ function ConvertTo-PowerShellBatchBudgetSafeSegment {
96
+ <#
97
+ .SYNOPSIS
98
+ Reduces a session id to characters that are safe in a file name.
99
+ #>
100
+ [CmdletBinding()]
101
+ [OutputType([string])]
102
+ param(
103
+ [AllowNull()]
104
+ [AllowEmptyString()]
105
+ [string] $Value
106
+ )
107
+
108
+ return ($Value -replace '[^A-Za-z0-9._-]', '_')
109
+ }
110
+
111
+ function Get-PowerShellBatchBudgetSessionId {
112
+ <#
113
+ .SYNOPSIS
114
+ Resolves the session id used to compose the batch-budget state-file name.
115
+ .DESCRIPTION
116
+ Returns the first non-empty of: the explicit SessionId argument; the
117
+ CLAUDE_SESSION_ID environment variable; the contents of the session-id state
118
+ file; a worktree-derived identifier. The result is sanitized so it cannot
119
+ escape the state directory. The session-id state file is read through the
120
+ ReadSessionIdFile seam and is never created, so resolution performs no write.
121
+ #>
122
+ [CmdletBinding()]
123
+ [OutputType([string])]
124
+ param(
125
+ [AllowNull()]
126
+ [AllowEmptyString()]
127
+ [string] $SessionId,
128
+
129
+ [AllowNull()]
130
+ [AllowEmptyString()]
131
+ [string] $Root,
132
+
133
+ [Parameter(Mandatory)]
134
+ [string] $SessionIdFilePath,
135
+
136
+ [scriptblock] $ReadSessionIdFile
137
+ )
138
+
139
+ $candidates = @(
140
+ $SessionId
141
+ $env:CLAUDE_SESSION_ID
142
+ )
143
+
144
+ foreach ($candidate in $candidates) {
145
+ if (-not [string]::IsNullOrWhiteSpace($candidate)) {
146
+ return (ConvertTo-PowerShellBatchBudgetSafeSegment -Value $candidate.Trim())
147
+ }
148
+ }
149
+
150
+ $fromFile = ''
151
+ try {
152
+ $fromFile = [string](& $ReadSessionIdFile $SessionIdFilePath)
153
+ } catch {
154
+ Write-Verbose "Ignoring unreadable session-id file '$SessionIdFilePath': $($_.Exception.Message)"
155
+ $fromFile = ''
156
+ }
157
+
158
+ if (-not [string]::IsNullOrWhiteSpace($fromFile)) {
159
+ return (ConvertTo-PowerShellBatchBudgetSafeSegment -Value $fromFile.Trim())
160
+ }
161
+
162
+ $normalizedRoot = ($Root -replace '\\', '/').TrimEnd('/')
163
+ $leaf = ConvertTo-PowerShellBatchBudgetSafeSegment -Value (Split-Path -Path $normalizedRoot -Leaf)
164
+
165
+ $sha = [System.Security.Cryptography.SHA256]::Create()
166
+ try {
167
+ $hashBytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($normalizedRoot))
168
+ } finally {
169
+ $sha.Dispose()
170
+ }
171
+ $shortHash = -join (@($hashBytes[0..3]) | ForEach-Object { $_.ToString('x2') })
172
+
173
+ return "worktree-$leaf-$shortHash"
174
+ }
175
+
42
176
  function Get-PowerShellBatchBudgetState {
43
177
  [CmdletBinding()]
44
178
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -69,14 +203,26 @@ function ConvertTo-PowerShellBatchBudgetState {
69
203
  [int] $ProdCap,
70
204
 
71
205
  [Parameter(Mandatory)]
72
- [int] $TestCap
206
+ [int] $TestCap,
207
+
208
+ [AllowNull()]
209
+ [AllowEmptyString()]
210
+ [string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent)
73
211
  )
74
212
 
75
213
  $state = Get-PowerShellBatchBudgetState -ProdCap $ProdCap -TestCap $TestCap
76
214
  if ($null -ne $InputObject.prodCap) { $state.prodCap = [int]$InputObject.prodCap }
77
215
  if ($null -ne $InputObject.testCap) { $state.testCap = [int]$InputObject.testCap }
78
- if ($null -ne $InputObject.prodFiles) { $state.prodFiles = @($InputObject.prodFiles) }
79
- if ($null -ne $InputObject.testFiles) { $state.testFiles = @($InputObject.testFiles) }
216
+
217
+ # Persisted entries that resolve outside this root belong to another worktree
218
+ # and are dropped, so a state file carried across worktrees cannot spend this
219
+ # worktree's budget.
220
+ if ($null -ne $InputObject.prodFiles) {
221
+ $state.prodFiles = @(@($InputObject.prodFiles) | Where-Object { Test-PowerShellBatchBudgetPathInRoot -Path $_ -Root $Root })
222
+ }
223
+ if ($null -ne $InputObject.testFiles) {
224
+ $state.testFiles = @(@($InputObject.testFiles) | Where-Object { Test-PowerShellBatchBudgetPathInRoot -Path $_ -Root $Root })
225
+ }
80
226
 
81
227
  return $state
82
228
  }
@@ -116,7 +262,11 @@ function Invoke-PowerShellBatchBudgetDecision {
116
262
  [System.Collections.IDictionary] $State,
117
263
 
118
264
  [Parameter(Mandatory)]
119
- [string] $StateFile
265
+ [string] $StateFile,
266
+
267
+ [AllowNull()]
268
+ [AllowEmptyString()]
269
+ [string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent)
120
270
  )
121
271
 
122
272
  $normalized = $FilePath -replace '\\', '/'
@@ -124,6 +274,13 @@ function Invoke-PowerShellBatchBudgetDecision {
124
274
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
125
275
  }
126
276
 
277
+ # An out-of-root candidate is discarded rather than denied: it consumes no
278
+ # slot and writes no state, so this hook stays deny-only for real overruns.
279
+ if (-not (Test-PowerShellBatchBudgetPathInRoot -Path $normalized -Root $Root)) {
280
+ Write-Verbose "Discarding batch-budget candidate '$normalized': it resolves outside the batch-budget root '$Root'."
281
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
282
+ }
283
+
127
284
  $isTestFile = ($normalized -match '(^|/)tests/.*\.ps1$') -or ($normalized -match '\.Tests\.ps1$')
128
285
  $targetList = if ($isTestFile) { @($State.testFiles) } else { @($State.prodFiles) }
129
286
  $cap = if ($isTestFile) { [int]$State.testCap } else { [int]$State.prodCap }
@@ -154,10 +311,17 @@ function Invoke-PowerShellBatchBudgetHook {
154
311
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
155
312
  param(
156
313
  [string] $ToolInputRaw,
157
- [string] $SessionId = 'default',
158
- [string] $Root = (Get-Location).Path,
314
+ [string] $SessionId = '',
315
+ [string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent),
159
316
  [int] $ProdCap = 3,
160
317
  [int] $TestCap = 3,
318
+ [scriptblock] $ReadSessionIdFile = {
319
+ param([string] $Path)
320
+ if (Test-Path -LiteralPath $Path -PathType Leaf) {
321
+ return (Get-Content -LiteralPath $Path -Raw)
322
+ }
323
+ return ''
324
+ },
161
325
  [scriptblock] $TestPathExists = { param([string] $Path) Test-Path -Path $Path },
162
326
  [scriptblock] $EnsureDirectory = { param([string] $Path) New-Item -ItemType Directory -Path $Path -Force | Out-Null },
163
327
  [scriptblock] $ReadState = { param([string] $Path) Get-Content -Path $Path -Raw },
@@ -186,23 +350,32 @@ function Invoke-PowerShellBatchBudgetHook {
186
350
  }
187
351
 
188
352
  $stateDir = Join-Path -Path $Root -ChildPath '.claude/state'
353
+
354
+ # Resolved before the directory is ensured, because reading the session-id
355
+ # file must never be what creates the state directory.
356
+ $resolvedSessionId = Get-PowerShellBatchBudgetSessionId `
357
+ -SessionId $SessionId `
358
+ -Root $Root `
359
+ -SessionIdFilePath (Join-Path -Path $stateDir -ChildPath 'current-session-id') `
360
+ -ReadSessionIdFile $ReadSessionIdFile
361
+
189
362
  if (-not (& $TestPathExists $stateDir)) {
190
363
  & $EnsureDirectory $stateDir
191
364
  }
192
365
 
193
- $stateFile = Join-Path -Path $stateDir -ChildPath ("powershell-batch-budget.$SessionId.json")
366
+ $stateFile = Join-Path -Path $stateDir -ChildPath ("powershell-batch-budget.$resolvedSessionId.json")
194
367
  $state = Get-PowerShellBatchBudgetState -ProdCap $ProdCap -TestCap $TestCap
195
368
 
196
369
  if (& $TestPathExists $stateFile) {
197
370
  try {
198
371
  $loaded = & $ReadState $stateFile | ConvertFrom-Json -ErrorAction Stop
199
- $state = ConvertTo-PowerShellBatchBudgetState -InputObject $loaded -ProdCap $ProdCap -TestCap $TestCap
372
+ $state = ConvertTo-PowerShellBatchBudgetState -InputObject $loaded -ProdCap $ProdCap -TestCap $TestCap -Root $Root
200
373
  } catch {
201
374
  Write-Verbose "Ignoring unreadable PowerShell batch-budget state file '$stateFile': $($_.Exception.Message)"
202
375
  }
203
376
  }
204
377
 
205
- $decision = Invoke-PowerShellBatchBudgetDecision -FilePath $filePath -State $state -StateFile $stateFile
378
+ $decision = Invoke-PowerShellBatchBudgetDecision -FilePath $filePath -State $state -StateFile $stateFile -Root $Root
206
379
  if ($decision.shouldWriteState) {
207
380
  try {
208
381
  & $WriteState $stateFile $decision.state
@@ -245,10 +418,10 @@ function Invoke-PowerShellBatchBudgetEntryPoint {
245
418
  $ToolInputRaw = [string](& $ReadPayload)
246
419
  }
247
420
 
248
- $sessionId = $env:CLAUDE_SESSION_ID
249
- if (-not $sessionId) {
250
- $sessionId = 'default'
251
- }
421
+ # No literal fallback here: an empty value routes through the hook's session
422
+ # resolution, which falls back to the session-id state file and then to a
423
+ # worktree-derived identifier.
424
+ $sessionId = [string]$env:CLAUDE_SESSION_ID
252
425
 
253
426
  $prodCap = 3
254
427
  $testCap = 3