claude-dev-env 2.15.0 → 2.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/advisor/scripts/tier_model_ids.py +6 -2
- package/bin/install.mjs +35 -32
- package/bin/install.test.mjs +42 -8
- package/package.json +2 -2
- package/scripts/AGENTS.md +0 -7
- package/scripts/claude_chain_runner.py +49 -2
- package/scripts/invoke_code_review.py +48 -51
- package/scripts/resolve_worker_spawn.py +42 -47
- package/scripts/test_claude_chain_runner.py +28 -0
- package/scripts/test_dispatcher_profile_import.py +184 -0
- package/scripts/test_resolve_worker_spawn.py +119 -1
- package/scripts/test_validate_instruction_pairs.py +30 -0
- package/scripts/profile-isolation-launchers/config/mcp-bundles.json +0 -25
- package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +0 -60
- package/scripts/profile-isolation-launchers/config/profiles.manifest.json +0 -54
- package/scripts/profile-isolation-launchers/config/shared-allowlist.json +0 -64
- package/scripts/profile-isolation-launchers/launcher-runtime.mjs +0 -180
- package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +0 -288
- package/scripts/profile-isolation-launchers/mcp-bundles.mjs +0 -275
- package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +0 -221
- package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +0 -108
- package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +0 -147
- package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +0 -102
- package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +0 -210
- package/scripts/profile-isolation-launchers/version-compatibility.mjs +0 -299
- package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +0 -127
- package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +0 -51
- package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +0 -77
|
@@ -30,8 +30,12 @@ from collections.abc import Mapping
|
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
|
|
32
32
|
_config_directory = str(Path(__file__).resolve().parent / "config")
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
sys.path[:] = [
|
|
34
|
+
each_existing_entry
|
|
35
|
+
for each_existing_entry in sys.path
|
|
36
|
+
if each_existing_entry != _config_directory
|
|
37
|
+
]
|
|
38
|
+
sys.path[:0] = [_config_directory]
|
|
35
39
|
|
|
36
40
|
from advisor_scripts_constants.model_tier_run_validator_constants import ( # noqa: E402
|
|
37
41
|
ALL_CLI_MODEL_ID_BY_TIER,
|
package/bin/install.mjs
CHANGED
|
@@ -114,6 +114,35 @@ const RETIRED_SKILL_REASON_LABEL = 'retired';
|
|
|
114
114
|
const STALE_FILE_REASON_LABEL = 'stale';
|
|
115
115
|
const MANIFEST_MANAGED_PERMISSIONS_KEY = 'managedPermissions';
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Managed-home-relative directories this package leaves to whoever deployed
|
|
119
|
+
* them: it writes nothing inside them and prunes nothing out of them.
|
|
120
|
+
*
|
|
121
|
+
* `scripts/profile-isolation-launchers` is the live CLI profile launcher. Its
|
|
122
|
+
* executable modules are deployed from a separate source, so a payload covering
|
|
123
|
+
* only part of that tree left config and code out of step. This package stopped
|
|
124
|
+
* shipping its part, and the stale-file prune reads a path the package no longer
|
|
125
|
+
* writes as stale, which would move the live files aside on the next install.
|
|
126
|
+
* Naming the directory here keeps both halves of the tree with their owner.
|
|
127
|
+
*/
|
|
128
|
+
const RETAINED_UNMANAGED_RELATIVE_PATHS = [
|
|
129
|
+
'scripts/profile-isolation-launchers',
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Report whether a path a prior manifest recorded sits inside a directory this
|
|
134
|
+
* package leaves to another owner.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} candidatePath The absolute path the prior manifest recorded.
|
|
137
|
+
* @param {string} managedHomeDirectory The managed home the relative names resolve against.
|
|
138
|
+
* @returns {boolean} True when the path sits inside a retained unmanaged directory.
|
|
139
|
+
*/
|
|
140
|
+
function isRetainedUnmanagedPath(candidatePath, managedHomeDirectory) {
|
|
141
|
+
return RETAINED_UNMANAGED_RELATIVE_PATHS.some(
|
|
142
|
+
relativePath => isInsideDirectory(candidatePath, join(managedHomeDirectory, relativePath)),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
117
146
|
export const CORE_INCLUDE_DIRECTORIES = [
|
|
118
147
|
'rules', 'docs', 'commands', 'agents', 'audit-rubrics', '_shared', 'scripts',
|
|
119
148
|
];
|
|
@@ -832,6 +861,7 @@ export function pruneStaleInstalledFiles(
|
|
|
832
861
|
const stalePath = resolve(priorFile);
|
|
833
862
|
if (!isInsideDirectory(stalePath, resolvedRoot)) continue;
|
|
834
863
|
if (currentFileKeys.has(comparisonKeyForPath(stalePath, options))) continue;
|
|
864
|
+
if (isRetainedUnmanagedPath(stalePath, managedHomeDirectory)) continue;
|
|
835
865
|
if (!isMovableStaleFile(stalePath)) continue;
|
|
836
866
|
const backupRelativePath = relative(resolvedRoot, stalePath);
|
|
837
867
|
const didMove = moveIntoRunBackup(
|
|
@@ -2590,13 +2620,15 @@ function realPathOrSelf(filesystemPath) {
|
|
|
2590
2620
|
}
|
|
2591
2621
|
|
|
2592
2622
|
/**
|
|
2593
|
-
* Load profile id → directoryName
|
|
2594
|
-
*
|
|
2623
|
+
* Load profile id → directoryName for the install targets this package resolves.
|
|
2624
|
+
*
|
|
2625
|
+
* Each profile's directory carries the profile's own name, so the map is an
|
|
2626
|
+
* identity over the ids a multi-target install accepts.
|
|
2595
2627
|
*
|
|
2596
2628
|
* @returns {Record<string, string>}
|
|
2597
2629
|
*/
|
|
2598
2630
|
function loadDirectoryNameByProfileId() {
|
|
2599
|
-
|
|
2631
|
+
return {
|
|
2600
2632
|
main: 'main',
|
|
2601
2633
|
editor: 'editor',
|
|
2602
2634
|
mel: 'mel',
|
|
@@ -2604,35 +2636,6 @@ function loadDirectoryNameByProfileId() {
|
|
|
2604
2636
|
master: 'master',
|
|
2605
2637
|
kimi: 'kimi',
|
|
2606
2638
|
};
|
|
2607
|
-
const manifestPath = join(
|
|
2608
|
-
PACKAGE_ROOT,
|
|
2609
|
-
'scripts',
|
|
2610
|
-
'profile-isolation-launchers',
|
|
2611
|
-
'config',
|
|
2612
|
-
'profiles.manifest.json',
|
|
2613
|
-
);
|
|
2614
|
-
if (!existsSync(manifestPath)) {
|
|
2615
|
-
return fallbackDirectoryNameByProfileId;
|
|
2616
|
-
}
|
|
2617
|
-
try {
|
|
2618
|
-
const document = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
2619
|
-
/** @type {Record<string, string>} */
|
|
2620
|
-
const directoryNameByProfileId = {};
|
|
2621
|
-
const profiles = document && typeof document === 'object' ? document.profiles : null;
|
|
2622
|
-
if (profiles && typeof profiles === 'object') {
|
|
2623
|
-
for (const [eachProfileId, eachProfile] of Object.entries(profiles)) {
|
|
2624
|
-
if (eachProfile && typeof eachProfile === 'object' && typeof eachProfile.directoryName === 'string') {
|
|
2625
|
-
directoryNameByProfileId[eachProfileId] = eachProfile.directoryName;
|
|
2626
|
-
}
|
|
2627
|
-
}
|
|
2628
|
-
}
|
|
2629
|
-
if (Object.keys(directoryNameByProfileId).length === 0) {
|
|
2630
|
-
return fallbackDirectoryNameByProfileId;
|
|
2631
|
-
}
|
|
2632
|
-
return directoryNameByProfileId;
|
|
2633
|
-
} catch {
|
|
2634
|
-
return fallbackDirectoryNameByProfileId;
|
|
2635
|
-
}
|
|
2636
2639
|
}
|
|
2637
2640
|
|
|
2638
2641
|
/**
|
package/bin/install.test.mjs
CHANGED
|
@@ -1281,25 +1281,59 @@ const README_BASENAME_PATTERN = /^readme\.md$/i;
|
|
|
1281
1281
|
|
|
1282
1282
|
|
|
1283
1283
|
/**
|
|
1284
|
-
* Build a sandbox holding
|
|
1284
|
+
* Build a sandbox holding one installed managed root and a run backup root.
|
|
1285
1285
|
*
|
|
1286
|
-
* @param {object} installedFiles Forward-slash relative paths under the
|
|
1287
|
-
* @
|
|
1286
|
+
* @param {object} installedFiles Forward-slash relative paths under the installed root mapped to contents.
|
|
1287
|
+
* @param {string} [managedRootName] The managed top-level directory the files sit under.
|
|
1288
|
+
* @returns {{root: string, skillsRoot: string, installedRoot: string, backupRoot: string}} The sandbox paths.
|
|
1288
1289
|
*/
|
|
1289
|
-
function createStalePruneSandbox(installedFiles) {
|
|
1290
|
+
function createStalePruneSandbox(installedFiles, managedRootName = 'skills') {
|
|
1290
1291
|
const root = mkdtempSync(join(tmpdir(), 'cdev-stale-prune-'));
|
|
1291
|
-
const
|
|
1292
|
+
const installedRoot = join(root, managedRootName);
|
|
1292
1293
|
const backupRoot = join(root, 'pruned', 'run-timestamp');
|
|
1293
|
-
mkdirSync(
|
|
1294
|
+
mkdirSync(installedRoot, { recursive: true });
|
|
1294
1295
|
for (const [relativePath, contents] of Object.entries(installedFiles)) {
|
|
1295
|
-
const targetPath = join(
|
|
1296
|
+
const targetPath = join(installedRoot, relativePath);
|
|
1296
1297
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
1297
1298
|
writeFileSync(targetPath, contents);
|
|
1298
1299
|
}
|
|
1299
|
-
return { root, skillsRoot, backupRoot };
|
|
1300
|
+
return { root, skillsRoot: installedRoot, installedRoot, backupRoot };
|
|
1300
1301
|
}
|
|
1301
1302
|
|
|
1302
1303
|
|
|
1304
|
+
test('pruneStaleInstalledFiles leaves a retained unmanaged path the package stopped shipping in place', () => {
|
|
1305
|
+
const sandbox = createStalePruneSandbox({
|
|
1306
|
+
'profile-isolation-launchers/config/mcp-bundles.json': '{"schemaVersion":1}\n',
|
|
1307
|
+
'sync-to-cursor.py': 'print("shipped")\n',
|
|
1308
|
+
}, 'scripts');
|
|
1309
|
+
try {
|
|
1310
|
+
const shippedFilePath = join(sandbox.installedRoot, 'sync-to-cursor.py');
|
|
1311
|
+
const retainedFilePath = join(
|
|
1312
|
+
sandbox.installedRoot, 'profile-isolation-launchers', 'config', 'mcp-bundles.json',
|
|
1313
|
+
);
|
|
1314
|
+
|
|
1315
|
+
const pruneOutcome = pruneStaleInstalledFiles(
|
|
1316
|
+
[shippedFilePath, retainedFilePath],
|
|
1317
|
+
[shippedFilePath],
|
|
1318
|
+
sandbox.installedRoot,
|
|
1319
|
+
sandbox.backupRoot,
|
|
1320
|
+
{ managedHomeDirectory: sandbox.root },
|
|
1321
|
+
);
|
|
1322
|
+
|
|
1323
|
+
assert.equal(pruneOutcome.prunedCount, 0, 'a retained unmanaged path never counts as pruned');
|
|
1324
|
+
assert.deepEqual(pruneOutcome.failedPaths, [], 'skipping a retained path reports no failed move');
|
|
1325
|
+
assert.equal(
|
|
1326
|
+
existsSync(retainedFilePath),
|
|
1327
|
+
true,
|
|
1328
|
+
'the launcher file a prior install recorded stays where the live launcher reads it',
|
|
1329
|
+
);
|
|
1330
|
+
assert.equal(existsSync(shippedFilePath), true, 'a file this run wrote stays in place');
|
|
1331
|
+
} finally {
|
|
1332
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
|
|
1303
1337
|
/**
|
|
1304
1338
|
* Run a callable with console.warn captured, returning its value and the warnings.
|
|
1305
1339
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-dev-env",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.1",
|
|
4
4
|
"description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"codex-compat": "bin/codex-compat.mjs"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node --test \"bin/*.test.mjs\" \"skills/**/*.test.mjs\" \"
|
|
11
|
+
"test": "node --test \"bin/*.test.mjs\" \"skills/**/*.test.mjs\" \"tests/fresh-session/**/*.test.mjs\""
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"bin/",
|
package/scripts/AGENTS.md
CHANGED
|
@@ -37,7 +37,6 @@ Utility scripts installed into `~/.claude/scripts/` by `bin/install.mjs`. Each s
|
|
|
37
37
|
|---|---|
|
|
38
38
|
| `ci/` | CI-only adapters; `windows-installer-lifecycle.ps1` runs the Node installer 16-check driver under isolated HOME/USERPROFILE/GIT_CONFIG_GLOBAL and writes bounded evidence |
|
|
39
39
|
| `dev_env_scripts_constants/` | Named constants (`timing.py`, `grok_worker_constants.py`, …) for scripts in this directory, including worker-advisor placeholder launcher/model/effort, four verdict signals, correction cap, and advisor timeout |
|
|
40
|
-
| `profile-isolation-launchers/` | Profile-launcher contract plus MCP activation, Windows shortcut source semantics, and version preflight: profiles manifest, shared-path allowlist, profile resolver, `CLAUDE_CONFIG_DIR` authority, lean/full MCP bundle materialization into profile `mcp.json`, pure CLI/Desktop version-compatibility classifier consumed by launcher preflight, read-only shortcut inventory and preview reconcile under `windows/`. Live launcher execution, provenance pin, live shortcut mutation, and L1 deploy stay residual |
|
|
41
40
|
| `sync_to_cursor/` | Package that builds Cursor `.mdc` files from Claude rules and docs |
|
|
42
41
|
| `tests/` | pytest suite for the Python scripts and Pester (`*.Tests.ps1`) suite for the PowerShell scripts in this directory |
|
|
43
42
|
|
|
@@ -49,12 +48,6 @@ Python scripts (pytest):
|
|
|
49
48
|
python -m pytest packages/claude-dev-env/scripts/tests/
|
|
50
49
|
```
|
|
51
50
|
|
|
52
|
-
Profile-isolation launcher contract (node:test):
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
node --test packages/claude-dev-env/scripts/profile-isolation-launchers/**/*.test.mjs
|
|
56
|
-
```
|
|
57
|
-
|
|
58
51
|
PowerShell scripts (Pester 5+, `*.Tests.ps1`):
|
|
59
52
|
|
|
60
53
|
```powershell
|
|
@@ -48,7 +48,9 @@ import os
|
|
|
48
48
|
import subprocess
|
|
49
49
|
import sys
|
|
50
50
|
import tempfile
|
|
51
|
-
|
|
51
|
+
import threading
|
|
52
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
53
|
+
from contextlib import contextmanager
|
|
52
54
|
from dataclasses import dataclass, field
|
|
53
55
|
from pathlib import Path
|
|
54
56
|
from types import ModuleType
|
|
@@ -327,6 +329,35 @@ class WeeklyUsageAccountReport(Protocol):
|
|
|
327
329
|
|
|
328
330
|
|
|
329
331
|
chain_subprocess_runner = _run_captured_subprocess
|
|
332
|
+
_shared_chain_subprocess_lock = threading.Lock()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def chain_subprocess_runner_lock() -> threading.Lock:
|
|
336
|
+
"""Return the lock for adapters that temporarily configure the runner."""
|
|
337
|
+
return _shared_chain_subprocess_lock
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@contextmanager
|
|
341
|
+
def override_chain_subprocess_runner(
|
|
342
|
+
replacement_runner: Callable[..., subprocess.CompletedProcess[str]],
|
|
343
|
+
) -> Iterator[Callable[..., subprocess.CompletedProcess[str]]]:
|
|
344
|
+
"""Temporarily replace the chain subprocess seam under its shared lock.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
replacement_runner: Callable used for subprocess invocations during the
|
|
348
|
+
context.
|
|
349
|
+
|
|
350
|
+
Yields:
|
|
351
|
+
The runner that was active before the replacement.
|
|
352
|
+
"""
|
|
353
|
+
global chain_subprocess_runner
|
|
354
|
+
with chain_subprocess_runner_lock():
|
|
355
|
+
previous_runner = chain_subprocess_runner
|
|
356
|
+
chain_subprocess_runner = replacement_runner
|
|
357
|
+
try:
|
|
358
|
+
yield previous_runner
|
|
359
|
+
finally:
|
|
360
|
+
chain_subprocess_runner = previous_runner
|
|
330
361
|
|
|
331
362
|
|
|
332
363
|
def _load_chain_usage_module() -> ModuleType:
|
|
@@ -1138,6 +1169,22 @@ def _persist_served_affinity(
|
|
|
1138
1169
|
return
|
|
1139
1170
|
|
|
1140
1171
|
|
|
1172
|
+
def _run_cli_chain(
|
|
1173
|
+
all_claude_arguments: list[str],
|
|
1174
|
+
*,
|
|
1175
|
+
timeout_seconds: int,
|
|
1176
|
+
stdin_text: str | None,
|
|
1177
|
+
routing_mode: str,
|
|
1178
|
+
) -> ChainInvocationOutcome:
|
|
1179
|
+
"""Run the CLI-selected chain arguments through the public runner."""
|
|
1180
|
+
return run_claude(
|
|
1181
|
+
all_claude_arguments,
|
|
1182
|
+
timeout_seconds=timeout_seconds,
|
|
1183
|
+
stdin_text=stdin_text,
|
|
1184
|
+
routing_mode=routing_mode,
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
|
|
1141
1188
|
def _build_argument_parser() -> argparse.ArgumentParser:
|
|
1142
1189
|
parser = argparse.ArgumentParser(
|
|
1143
1190
|
description="Run a claude invocation through the fallback chain."
|
|
@@ -1206,7 +1253,7 @@ def main(all_command_arguments: list[str]) -> int:
|
|
|
1206
1253
|
all_claude_arguments = _strip_leading_separator(parsed_arguments.passthrough)
|
|
1207
1254
|
maybe_stdin_text = _read_piped_stdin_text()
|
|
1208
1255
|
try:
|
|
1209
|
-
chain_outcome =
|
|
1256
|
+
chain_outcome = _run_cli_chain(
|
|
1210
1257
|
all_claude_arguments,
|
|
1211
1258
|
timeout_seconds=parsed_arguments.timeout_seconds,
|
|
1212
1259
|
stdin_text=maybe_stdin_text,
|
|
@@ -28,20 +28,30 @@ import argparse
|
|
|
28
28
|
import json
|
|
29
29
|
import subprocess
|
|
30
30
|
import sys
|
|
31
|
-
import threading
|
|
32
31
|
from collections.abc import Callable, Sequence
|
|
33
32
|
from dataclasses import dataclass
|
|
34
33
|
from pathlib import Path
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
_scripts_directory_path = Path(__file__).resolve().parent
|
|
36
|
+
_scripts_directory = str(_scripts_directory_path)
|
|
37
|
+
sys.path[:] = [
|
|
38
|
+
each_existing_entry
|
|
39
|
+
for each_existing_entry in sys.path
|
|
40
|
+
if each_existing_entry != _scripts_directory
|
|
41
|
+
]
|
|
42
|
+
sys.path[:0] = [_scripts_directory]
|
|
38
43
|
|
|
39
44
|
_advisor_scripts_path = str(
|
|
40
|
-
|
|
45
|
+
_scripts_directory_path.parent / "_shared" / "advisor" / "scripts"
|
|
41
46
|
)
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
sys.path[:] = [
|
|
48
|
+
each_existing_entry
|
|
49
|
+
for each_existing_entry in sys.path
|
|
50
|
+
if each_existing_entry != _advisor_scripts_path
|
|
51
|
+
]
|
|
52
|
+
sys.path[:0] = [_advisor_scripts_path]
|
|
44
53
|
|
|
54
|
+
from tier_model_ids import detect_host_profile # noqa: E402
|
|
45
55
|
from advisor_scripts_constants.model_tier_run_validator_constants import ( # noqa: E402
|
|
46
56
|
HOST_PROFILE_CLAUDE,
|
|
47
57
|
)
|
|
@@ -93,14 +103,6 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
|
93
103
|
from dev_env_scripts_constants.timing import ( # noqa: E402
|
|
94
104
|
DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
|
|
95
105
|
)
|
|
96
|
-
from tier_model_ids import detect_host_profile # noqa: E402
|
|
97
|
-
|
|
98
|
-
_CHAIN_RUNNER_LOCK = threading.Lock()
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _chain_runner_lock() -> threading.Lock:
|
|
102
|
-
"""Return the module lock that serializes chain-runner stdin/cwd swaps."""
|
|
103
|
-
return _CHAIN_RUNNER_LOCK
|
|
104
106
|
|
|
105
107
|
|
|
106
108
|
@dataclass(frozen=True)
|
|
@@ -302,44 +304,39 @@ def _run_claude_with_empty_stdin(
|
|
|
302
304
|
working_directory: Path,
|
|
303
305
|
) -> ChainInvocationOutcome:
|
|
304
306
|
working_directory_path = str(working_directory)
|
|
305
|
-
with _CHAIN_RUNNER_LOCK:
|
|
306
|
-
previous_runner: TextCapturingSubprocessRunner = (
|
|
307
|
-
chain_runner.chain_subprocess_runner
|
|
308
|
-
)
|
|
309
307
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
setattr(chain_runner, "chain_subprocess_runner", previous_runner)
|
|
308
|
+
def _runner_with_empty_stdin(
|
|
309
|
+
all_invocation_tokens: Sequence[str],
|
|
310
|
+
*all_positionals: object,
|
|
311
|
+
**all_keywords: object,
|
|
312
|
+
) -> subprocess.CompletedProcess[str]:
|
|
313
|
+
del all_positionals
|
|
314
|
+
maybe_timeout = all_keywords.get("timeout")
|
|
315
|
+
timeout_for_run: float | None
|
|
316
|
+
if isinstance(maybe_timeout, (int, float)):
|
|
317
|
+
timeout_for_run = float(maybe_timeout)
|
|
318
|
+
else:
|
|
319
|
+
timeout_for_run = None
|
|
320
|
+
forwarded_text_codec = collect_forwarded_text_codec(all_keywords)
|
|
321
|
+
completed_process: subprocess.CompletedProcess[str] = previous_runner(
|
|
322
|
+
all_invocation_tokens,
|
|
323
|
+
capture_output=True,
|
|
324
|
+
text=True,
|
|
325
|
+
timeout=timeout_for_run,
|
|
326
|
+
check=False,
|
|
327
|
+
stdin=subprocess.DEVNULL,
|
|
328
|
+
cwd=working_directory_path,
|
|
329
|
+
**forwarded_text_codec,
|
|
330
|
+
)
|
|
331
|
+
return completed_process
|
|
332
|
+
|
|
333
|
+
empty_stdin_runner: TextCapturingSubprocessRunner = _runner_with_empty_stdin
|
|
334
|
+
with chain_runner.override_chain_subprocess_runner(
|
|
335
|
+
empty_stdin_runner
|
|
336
|
+
) as previous_runner:
|
|
337
|
+
return review_claude_runner(
|
|
338
|
+
all_claude_arguments, timeout_seconds=timeout_seconds
|
|
339
|
+
)
|
|
343
340
|
|
|
344
341
|
|
|
345
342
|
def _in_session_outcome() -> CodeReviewOutcome:
|
|
@@ -26,21 +26,31 @@ import argparse
|
|
|
26
26
|
import json
|
|
27
27
|
import subprocess
|
|
28
28
|
import sys
|
|
29
|
-
import threading
|
|
30
29
|
from collections.abc import Callable, Sequence
|
|
31
30
|
from dataclasses import dataclass
|
|
32
31
|
from pathlib import Path
|
|
33
32
|
from typing import IO
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
_scripts_directory_path = Path(__file__).resolve().parent
|
|
35
|
+
_scripts_directory = str(_scripts_directory_path)
|
|
36
|
+
sys.path[:] = [
|
|
37
|
+
each_existing_entry
|
|
38
|
+
for each_existing_entry in sys.path
|
|
39
|
+
if each_existing_entry != _scripts_directory
|
|
40
|
+
]
|
|
41
|
+
sys.path[:0] = [_scripts_directory]
|
|
37
42
|
|
|
38
43
|
_advisor_scripts_path = str(
|
|
39
|
-
|
|
44
|
+
_scripts_directory_path.parent / "_shared" / "advisor" / "scripts"
|
|
40
45
|
)
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
sys.path[:] = [
|
|
47
|
+
each_existing_entry
|
|
48
|
+
for each_existing_entry in sys.path
|
|
49
|
+
if each_existing_entry != _advisor_scripts_path
|
|
50
|
+
]
|
|
51
|
+
sys.path[:0] = [_advisor_scripts_path]
|
|
43
52
|
|
|
53
|
+
from tier_model_ids import detect_host_profile # noqa: E402
|
|
44
54
|
from advisor_scripts_constants.model_tier_run_validator_constants import ( # noqa: E402
|
|
45
55
|
HOST_PROFILE_CLAUDE,
|
|
46
56
|
)
|
|
@@ -94,14 +104,6 @@ from grok_headless_runner import ( # noqa: E402
|
|
|
94
104
|
run_headless_worker,
|
|
95
105
|
)
|
|
96
106
|
from grok_worker_preflight import PreflightOutcome, run_preflight # noqa: E402
|
|
97
|
-
from tier_model_ids import detect_host_profile # noqa: E402
|
|
98
|
-
|
|
99
|
-
_HEADLESS_CHAIN_RUNNER_LOCK = threading.Lock()
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _headless_chain_runner_lock() -> threading.Lock:
|
|
103
|
-
"""Return the module lock that serializes headless chain-runner swaps."""
|
|
104
|
-
return _HEADLESS_CHAIN_RUNNER_LOCK
|
|
105
107
|
|
|
106
108
|
|
|
107
109
|
@dataclass(frozen=True)
|
|
@@ -184,41 +186,34 @@ def _run_claude_with_headless_overrides(
|
|
|
184
186
|
prompt_stdin: IO[str],
|
|
185
187
|
) -> ChainInvocationOutcome:
|
|
186
188
|
working_directory_path = str(working_directory)
|
|
187
|
-
with _HEADLESS_CHAIN_RUNNER_LOCK:
|
|
188
|
-
previous_runner: TextCapturingSubprocessRunner = (
|
|
189
|
-
chain_runner.chain_subprocess_runner
|
|
190
|
-
)
|
|
191
189
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
190
|
+
def _runner_with_headless_overrides(
|
|
191
|
+
all_invocation_tokens: Sequence[str],
|
|
192
|
+
*all_positionals: object,
|
|
193
|
+
**all_keywords: object,
|
|
194
|
+
) -> subprocess.CompletedProcess[str]:
|
|
195
|
+
del all_positionals
|
|
196
|
+
prompt_stdin.seek(0)
|
|
197
|
+
forwarded_text_codec = collect_forwarded_text_codec(all_keywords)
|
|
198
|
+
completed_process: subprocess.CompletedProcess[str] = previous_runner(
|
|
199
|
+
all_invocation_tokens,
|
|
200
|
+
capture_output=True,
|
|
201
|
+
text=True,
|
|
202
|
+
timeout=_timeout_seconds_from_keywords(all_keywords),
|
|
203
|
+
check=False,
|
|
204
|
+
stdin=prompt_stdin,
|
|
205
|
+
cwd=working_directory_path,
|
|
206
|
+
**forwarded_text_codec,
|
|
207
|
+
)
|
|
208
|
+
return completed_process
|
|
209
|
+
|
|
210
|
+
headless_runner: TextCapturingSubprocessRunner = _runner_with_headless_overrides
|
|
211
|
+
with chain_runner.override_chain_subprocess_runner(
|
|
212
|
+
headless_runner
|
|
213
|
+
) as previous_runner:
|
|
214
|
+
return spawn_claude_runner(
|
|
215
|
+
all_claude_arguments, timeout_seconds=timeout_seconds
|
|
214
216
|
)
|
|
215
|
-
setattr(chain_runner, "chain_subprocess_runner", headless_runner)
|
|
216
|
-
try:
|
|
217
|
-
return spawn_claude_runner(
|
|
218
|
-
all_claude_arguments, timeout_seconds=timeout_seconds
|
|
219
|
-
)
|
|
220
|
-
finally:
|
|
221
|
-
setattr(chain_runner, "chain_subprocess_runner", previous_runner)
|
|
222
217
|
|
|
223
218
|
|
|
224
219
|
def _prompt_file_unreadable_outcome(
|
|
@@ -17,6 +17,8 @@ import claude_chain_runner as runner # noqa: E402
|
|
|
17
17
|
import claude_chain_usage as chain_usage # noqa: E402
|
|
18
18
|
from claude_chain_runner import ( # noqa: E402
|
|
19
19
|
ChainEntry,
|
|
20
|
+
chain_subprocess_runner_lock,
|
|
21
|
+
override_chain_subprocess_runner,
|
|
20
22
|
default_affinity_state_path,
|
|
21
23
|
extract_resume_session_id,
|
|
22
24
|
load_affinity_store,
|
|
@@ -78,6 +80,32 @@ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
|
|
|
78
80
|
|
|
79
81
|
_LARGE_CAPTURE_BYTE_COUNT = 400_000
|
|
80
82
|
_LARGE_CAPTURE_MARKER = "X"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_chain_subprocess_runner_lock_is_shared() -> None:
|
|
86
|
+
first_lock = chain_subprocess_runner_lock()
|
|
87
|
+
second_lock = chain_subprocess_runner_lock()
|
|
88
|
+
|
|
89
|
+
assert first_lock is second_lock
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_override_chain_subprocess_runner_restores_previous_runner() -> None:
|
|
93
|
+
previous_runner = runner.chain_subprocess_runner
|
|
94
|
+
|
|
95
|
+
def replacement_runner(
|
|
96
|
+
all_invocation_tokens: list[str],
|
|
97
|
+
**all_keywords: object,
|
|
98
|
+
) -> subprocess.CompletedProcess[str]:
|
|
99
|
+
del all_invocation_tokens, all_keywords
|
|
100
|
+
return subprocess.CompletedProcess([], 0, "", "")
|
|
101
|
+
|
|
102
|
+
with override_chain_subprocess_runner(replacement_runner) as active_runner:
|
|
103
|
+
assert active_runner is previous_runner
|
|
104
|
+
assert runner.chain_subprocess_runner is replacement_runner
|
|
105
|
+
|
|
106
|
+
assert runner.chain_subprocess_runner is previous_runner
|
|
107
|
+
|
|
108
|
+
|
|
81
109
|
_STDIN_ECHO_PAYLOAD = "charter body for spool path"
|
|
82
110
|
_UNDECODABLE_STDOUT_BYTES = b"ok \x90 end"
|
|
83
111
|
_DECODED_UNDECODABLE_STDOUT = "ok \ufffd end"
|