@danmoisan/drm-copilot-mcp 1.1.7 → 1.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/mcp-server.js +73 -1
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +6 -5
- package/resources/claude-customizations/.claude/agents/parallel-planner.md +24 -7
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 +8 -5
- package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +186 -13
- package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +186 -13
- package/resources/claude-customizations/.claude/hooks/persist-session-id.ps1 +16 -5
- package/resources/claude-customizations/.claude/lib/bash/parallel-lane-assertion.sh +495 -0
- package/resources/claude-customizations/.claude/lib/bash/report-lane-assertion.sh +169 -0
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +7 -5
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +4 -2
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +4 -2
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +2 -0
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 +5 -3
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusTokenShape.psm1 +2 -0
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +6 -4
- package/resources/claude-customizations/.claude/lib/codex-routing/CodexDeployment.psm1 +2 -0
- package/resources/claude-customizations/.claude/lib/codex-routing/CodexTopology.psm1 +2 -0
- package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryValidation.psm1 +16 -16
- package/resources/claude-customizations/.claude/lib/hook-payload/HookPayload.psm1 +2 -0
- package/resources/claude-customizations/.claude/lib/mermaid/MermaidGrammar.psm1 +2 -0
- package/resources/claude-customizations/.claude/lib/mermaid/MermaidLineScanner.psm1 +3 -1
- package/resources/claude-customizations/.claude/lib/mermaid/MermaidMarkdownFences.psm1 +2 -0
- package/resources/claude-customizations/.claude/lib/mermaid/MermaidValidation.psm1 +5 -3
- package/resources/claude-customizations/.claude/lib/model-routing/ModelRouting.psm1 +2 -0
- package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorState.psm1 +11 -0
- package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCheckpointValue.psm1 +2 -0
- package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCodexModelReceipts.psm1 +4 -2
- package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCodexTopologyReceipts.psm1 +4 -2
- package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 +9 -7
- package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCompletionChecks.psm1 +4 -2
- package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateModelReceipts.psm1 +4 -2
- package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateReceipts.psm1 +3 -1
- package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateRoutingContract.psm1 +4 -2
- package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateRoutingMatrix.psm1 +3 -1
- package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateUnconditional.psm1 +7 -5
- package/resources/claude-customizations/.claude/lib/requirements/GeneratedDocumentCounters.psm1 +12 -0
- package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +4 -4
- package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +12 -9
- package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +3 -3
- package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +8 -3
- package/resources/claude-customizations/config/blast-radius.json +2 -1
- package/resources/claude-customizations/pack-manifests/core.json +2 -0
- 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
|
-
|
|
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
|
@@ -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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
`"Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"`,
|
|
161
|
-
`"Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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 = '
|
|
158
|
-
[string] $Root = (
|
|
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.$
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|