claude-dev-env 8.43.4 → 8.43.6

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 (36) hide show
  1. package/_shared/advisor/advisor-protocol.md +2 -2
  2. package/_shared/advisor/reference/cli-chain.md +9 -12
  3. package/_shared/advisor/reference/lifecycle.md +1 -1
  4. package/_shared/advisor/reference/third-party-bind.md +3 -3
  5. package/bin/install.mjs +39 -3
  6. package/bin/install.shared-settings.test.mjs +46 -0
  7. package/bin/install.test.mjs +1 -1
  8. package/docs/second-claude-account.md +11 -13
  9. package/package.json +1 -1
  10. package/scripts/_code_review_test_support.py +29 -39
  11. package/scripts/claude_account_worker.py +46 -297
  12. package/scripts/claude_account_worker_process.py +18 -154
  13. package/scripts/claude_account_worker_report.py +8 -4
  14. package/scripts/claude_chain_usage.py +7 -271
  15. package/scripts/codec_forwarding_test_support.py +11 -65
  16. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +6 -24
  17. package/scripts/invoke_code_review.py +34 -56
  18. package/scripts/policy_lint/config/approved_test_pairs.py +1 -0
  19. package/scripts/resolve_worker_spawn.py +40 -72
  20. package/scripts/test_account_broker_guard.py +0 -4
  21. package/scripts/test_claude_account_worker.py +105 -350
  22. package/scripts/test_claude_chain_usage.py +50 -510
  23. package/scripts/test_dispatcher_profile_import.py +5 -9
  24. package/scripts/test_invoke_code_review.py +7 -6
  25. package/scripts/test_invoke_code_review_chain.py +26 -0
  26. package/scripts/test_invoke_code_review_cli.py +8 -8
  27. package/scripts/test_invoke_code_review_codec.py +3 -11
  28. package/scripts/test_invoke_code_review_contract.py +5 -1
  29. package/scripts/test_resolve_worker_spawn.py +118 -265
  30. package/scripts/test_resolve_worker_spawn_codec.py +9 -21
  31. package/scripts/tests/test_code_review_constants.py +2 -2
  32. package/scripts/tests/test_policy_lint_rules_pairing.py +13 -0
  33. package/scripts/claude_account_choice.py +0 -422
  34. package/scripts/claude_chain_runner.py +0 -1289
  35. package/scripts/test_claude_account_choice.py +0 -355
  36. package/scripts/test_claude_chain_runner.py +0 -1839
@@ -1,42 +1,17 @@
1
- #!/usr/bin/env python3
2
- """Report remaining weekly usage for every account in the Claude chain.
3
-
4
- Loads ``~/.claude/claude-chain.json`` (or a path the caller names), probes each
5
- entry's weekly utilization through the usage-pause OAuth endpoint, and prints a
6
- JSON report. Ranking is available as an importable helper; ``run_claude``
7
- consumes that ranking to choose try order.
8
-
9
- ::
10
-
11
- python claude_chain_usage.py
12
- {"accounts": [
13
- {"command": "claude", "weekly_remaining_percent": 58.0},
14
- {"command": "claude-profile-c", "weekly_remaining_percent": null, "error": "..."}
15
- ]}
16
- """
1
+ """Probe Claude account usage meters for the account broker."""
17
2
 
18
3
  from __future__ import annotations
19
4
 
20
- import argparse
21
5
  import http.client
22
6
  import importlib.util
23
- import json
24
7
  import sys
25
8
  import urllib.error
26
- from collections.abc import Callable
27
9
  from dataclasses import dataclass
28
10
  from datetime import datetime
29
11
  from pathlib import Path
30
12
  from types import ModuleType
31
13
 
32
- from dev_env_scripts_constants.claude_chain_constants import CHAIN_CONFIG_ERROR_EXIT_CODE
33
14
  from dev_env_scripts_constants.claude_chain_usage_constants import (
34
- CLI_CONFIG_PATH_FLAG,
35
- FULL_WEEKLY_PERCENT,
36
- JSON_ACCOUNTS_KEY,
37
- JSON_COMMAND_KEY,
38
- JSON_ERROR_KEY,
39
- JSON_WEEKLY_REMAINING_PERCENT_KEY,
40
15
  NO_ACCESS_TOKEN_ERROR_TEMPLATE,
41
16
  RESOLVE_USAGE_WINDOW_FILENAME,
42
17
  RESOLVE_USAGE_WINDOW_MISSING_ERROR_TEMPLATE,
@@ -46,38 +21,11 @@ from dev_env_scripts_constants.claude_chain_usage_constants import (
46
21
  USAGE_PAUSE_SKILL_DIRECTORY_NAME,
47
22
  USAGE_PAUSE_SKILL_NAME,
48
23
  USAGE_PROBE_FAILED_ERROR_TEMPLATE,
49
- WEEKLY_UTILIZATION_MISSING_ERROR,
50
24
  )
51
25
 
52
- import claude_chain_runner as chain_runner
53
-
54
- WeeklyUtilizationProbe = Callable[[Path], float]
55
-
56
26
 
57
27
  class WeeklyUtilizationProbeError(Exception):
58
- """Raised when the weekly_utilization_probe cannot measure one account."""
59
-
60
-
61
- @dataclass(frozen=True)
62
- class AccountUsageReport:
63
- """One chain account's remaining weekly usage, or the reason it is unknown."""
64
-
65
- command: str
66
- weekly_remaining_percent: float | None
67
- error: str | None = None
68
-
69
-
70
- def _default_credentials_path() -> Path:
71
- usage_window_resolver = _load_resolve_usage_window_module()
72
- return Path.home().joinpath(
73
- *usage_window_resolver.ALL_CREDENTIALS_RELATIVE_PATH_PARTS
74
- )
75
-
76
-
77
- def _credentials_path_for_entry(entry: chain_runner.ChainEntry) -> Path:
78
- if entry.credentials_path is None:
79
- return _default_credentials_path()
80
- return Path(entry.credentials_path).expanduser()
28
+ """Raised when an account's usage meters cannot be measured."""
81
29
 
82
30
 
83
31
  def _usage_pause_scripts_directory() -> Path:
@@ -104,9 +52,7 @@ def _load_resolve_usage_window_module() -> ModuleType:
104
52
  module_path = scripts_directory / RESOLVE_USAGE_WINDOW_FILENAME
105
53
  if not module_path.is_file():
106
54
  raise WeeklyUtilizationProbeError(
107
- RESOLVE_USAGE_WINDOW_MISSING_ERROR_TEMPLATE.format(
108
- module_path=module_path
109
- )
55
+ RESOLVE_USAGE_WINDOW_MISSING_ERROR_TEMPLATE.format(module_path=module_path)
110
56
  )
111
57
  scripts_directory_text = str(scripts_directory)
112
58
  was_path_inserted = False
@@ -120,9 +66,7 @@ def _load_resolve_usage_window_module() -> ModuleType:
120
66
  )
121
67
  if module_specification is None or module_specification.loader is None:
122
68
  raise WeeklyUtilizationProbeError(
123
- RESOLVE_USAGE_WINDOW_MISSING_ERROR_TEMPLATE.format(
124
- module_path=module_path
125
- )
69
+ RESOLVE_USAGE_WINDOW_MISSING_ERROR_TEMPLATE.format(module_path=module_path)
126
70
  )
127
71
  loaded_module = importlib.util.module_from_spec(module_specification)
128
72
  sys.modules[RESOLVE_USAGE_WINDOW_MODULE_NAME] = loaded_module
@@ -138,13 +82,7 @@ def _load_resolve_usage_window_module() -> ModuleType:
138
82
 
139
83
  @dataclass(frozen=True)
140
84
  class AccountUsageMeters:
141
- """One account's 5-hour and weekly meters, each field None when unread.
142
-
143
- ::
144
-
145
- {"five_hour": {"utilization": 42, ...}, "seven_day": {...}}
146
- -> AccountUsageMeters(42.0, <5-hour reset>, 63.0, <weekly reset>)
147
- """
85
+ """An account's short and weekly utilization and reset times."""
148
86
 
149
87
  session_utilization: float | None
150
88
  session_resets_at: datetime | None
@@ -157,9 +95,7 @@ def _fetch_account_usage_payload(
157
95
  ) -> dict[str, object]:
158
96
  now = datetime.now().astimezone()
159
97
  try:
160
- access_token = usage_window_resolver.read_oauth_access_token(
161
- credentials_path, now
162
- )
98
+ access_token = usage_window_resolver.read_oauth_access_token(credentials_path, now)
163
99
  if access_token is None:
164
100
  raise WeeklyUtilizationProbeError(
165
101
  NO_ACCESS_TOKEN_ERROR_TEMPLATE.format(credentials_path=credentials_path)
@@ -180,18 +116,7 @@ def _fetch_account_usage_payload(
180
116
 
181
117
 
182
118
  def probe_account_meters(credentials_path: Path) -> AccountUsageMeters:
183
- """Read one account's usage meters through the usage-pause OAuth probe.
184
-
185
- Args:
186
- credentials_path: The account's CLI credential file.
187
-
188
- Returns:
189
- The account's 5-hour and weekly meters.
190
-
191
- Raises:
192
- WeeklyUtilizationProbeError: When the credential yields no usable token
193
- or the probe request fails.
194
- """
119
+ """Read short and weekly usage meters for one account."""
195
120
  usage_window_resolver = _load_resolve_usage_window_module()
196
121
  usage_payload = _fetch_account_usage_payload(usage_window_resolver, credentials_path)
197
122
  usage_windows = usage_window_resolver.extract_usage_windows(usage_payload)
@@ -201,192 +126,3 @@ def probe_account_meters(credentials_path: Path) -> AccountUsageMeters:
201
126
  weekly_utilization=usage_windows.weekly_utilization,
202
127
  weekly_resets_at=usage_windows.weekly_resets_at,
203
128
  )
204
-
205
-
206
- def _probe_weekly_utilization(credentials_path: Path) -> float:
207
- weekly_utilization = probe_account_meters(credentials_path).weekly_utilization
208
- if weekly_utilization is None:
209
- raise WeeklyUtilizationProbeError(WEEKLY_UTILIZATION_MISSING_ERROR)
210
- return float(weekly_utilization)
211
-
212
-
213
- weekly_utilization_probe: WeeklyUtilizationProbe = _probe_weekly_utilization
214
-
215
-
216
- def weekly_remaining_from_utilization(weekly_utilization: float) -> float:
217
- """Convert weekly utilization percent into remaining weekly percent.
218
-
219
- ::
220
-
221
- weekly_remaining_from_utilization(42.0) -> 58.0
222
- weekly_remaining_from_utilization(9.0) -> 91.0
223
-
224
- Args:
225
- weekly_utilization: Used weekly capacity on the 0–100 scale.
226
-
227
- Returns:
228
- Remaining weekly capacity on the same 0–100 scale.
229
- """
230
- return FULL_WEEKLY_PERCENT - weekly_utilization
231
-
232
-
233
- def _report_for_entry(
234
- entry: chain_runner.ChainEntry,
235
- active_probe: WeeklyUtilizationProbe,
236
- ) -> AccountUsageReport:
237
- try:
238
- credentials_path = _credentials_path_for_entry(entry)
239
- weekly_utilization = active_probe(credentials_path)
240
- except (WeeklyUtilizationProbeError, ImportError, AttributeError) as probe_error:
241
- return AccountUsageReport(
242
- command=entry.command,
243
- weekly_remaining_percent=None,
244
- error=str(probe_error),
245
- )
246
- return AccountUsageReport(
247
- command=entry.command,
248
- weekly_remaining_percent=weekly_remaining_from_utilization(
249
- weekly_utilization
250
- ),
251
- )
252
-
253
-
254
- def report_chain_weekly_usage(*, config_path: Path) -> list[AccountUsageReport]:
255
- """Report remaining weekly usage for every account in the chain config.
256
-
257
- ::
258
-
259
- report_chain_weekly_usage(config_path=Path("chain.json"))
260
- -> [AccountUsageReport("claude", 58.0), ...]
261
-
262
- Uses the module-level ``weekly_utilization_probe`` (tests rebind it).
263
-
264
- Args:
265
- config_path: Chain configuration file to load.
266
-
267
- Returns:
268
- One report per chain entry, in config order.
269
-
270
- Raises:
271
- ChainConfigurationError: When the chain configuration cannot be loaded.
272
- """
273
- all_entries = chain_runner.load_chain(config_path)
274
- return [
275
- _report_for_entry(each_entry, weekly_utilization_probe)
276
- for each_entry in all_entries
277
- ]
278
-
279
-
280
- def _nulls_last_remaining_rank(report: AccountUsageReport) -> tuple[int, float]:
281
- if report.weekly_remaining_percent is None:
282
- return (1, 0.0)
283
- return (0, -report.weekly_remaining_percent)
284
-
285
-
286
- def rank_accounts_by_weekly_remaining(
287
- all_reports: list[AccountUsageReport],
288
- ) -> list[AccountUsageReport]:
289
- """Order reports by remaining weekly usage, highest first.
290
-
291
- ::
292
-
293
- [40%, 70%, null, 70%] -> [70% (earlier), 70% (later), 40%, null]
294
-
295
- Ties keep config order. Unmeasurable accounts (null remaining) sort last.
296
-
297
- Args:
298
- all_reports: Reports in chain-config order.
299
-
300
- Returns:
301
- A new list ordered by remaining weekly percent descending.
302
- """
303
- return sorted(all_reports, key=_nulls_last_remaining_rank)
304
-
305
-
306
- def _account_report_to_json_mapping(
307
- report: AccountUsageReport,
308
- ) -> dict[str, str | float | None]:
309
- if report.weekly_remaining_percent is None:
310
- return {
311
- JSON_COMMAND_KEY: report.command,
312
- JSON_WEEKLY_REMAINING_PERCENT_KEY: None,
313
- JSON_ERROR_KEY: report.error,
314
- }
315
- return {
316
- JSON_COMMAND_KEY: report.command,
317
- JSON_WEEKLY_REMAINING_PERCENT_KEY: report.weekly_remaining_percent,
318
- }
319
-
320
-
321
- def reports_to_json_payload(
322
- all_reports: list[AccountUsageReport],
323
- ) -> dict[str, list[dict[str, str | float | None]]]:
324
- """Build the CLI JSON payload for a list of account reports.
325
-
326
- ::
327
-
328
- reports_to_json_payload([AccountUsageReport("claude", 58.0)])
329
- -> {"accounts": [{"command": "claude", "weekly_remaining_percent": 58.0}]}
330
-
331
- Args:
332
- all_reports: Reports in the order they should appear in the payload.
333
-
334
- Returns:
335
- The ``{"accounts": [...]}`` envelope.
336
- """
337
- return {
338
- JSON_ACCOUNTS_KEY: [
339
- _account_report_to_json_mapping(each_report)
340
- for each_report in all_reports
341
- ]
342
- }
343
-
344
-
345
- def _build_argument_parser() -> argparse.ArgumentParser:
346
- parser = argparse.ArgumentParser(
347
- description=(
348
- "Report remaining weekly usage for every account in the Claude chain."
349
- )
350
- )
351
- parser.add_argument(
352
- CLI_CONFIG_PATH_FLAG,
353
- dest="config_path",
354
- default=None,
355
- help="Path to claude-chain.json; defaults to the per-user chain config.",
356
- )
357
- return parser
358
-
359
-
360
- def main(all_command_arguments: list[str]) -> int:
361
- """Probe the chain and print the weekly-usage JSON report on stdout.
362
-
363
- ::
364
-
365
- main(["--config-path", "chain.json"]) -> 0 # JSON on stdout
366
- main(["--config-path", "missing.json"]) -> 3 # error on stderr
367
-
368
- Args:
369
- all_command_arguments: The argument vector after the program name.
370
-
371
- Returns:
372
- Zero on success, or the chain-config error exit code when the config
373
- cannot be loaded.
374
- """
375
- parser = _build_argument_parser()
376
- parsed_arguments = parser.parse_args(all_command_arguments)
377
- resolved_config_path = (
378
- Path(parsed_arguments.config_path)
379
- if parsed_arguments.config_path is not None
380
- else chain_runner.chain_config_path()
381
- )
382
- try:
383
- all_reports = report_chain_weekly_usage(config_path=resolved_config_path)
384
- except chain_runner.ChainConfigurationError as configuration_error:
385
- print(str(configuration_error), file=sys.stderr)
386
- return CHAIN_CONFIG_ERROR_EXIT_CODE
387
- print(json.dumps(reports_to_json_payload(all_reports), ensure_ascii=True))
388
- return 0
389
-
390
-
391
- if __name__ == "__main__":
392
- sys.exit(main(sys.argv[1:]))
@@ -1,85 +1,31 @@
1
- """Shared fixtures for text-codec keyword-forwarding behavioral tests."""
1
+ """Shared fixtures for broker keyword forwarding tests."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import subprocess
6
- from collections.abc import Sequence
7
-
8
- import claude_chain_runner as chain_runner
9
5
  import pytest
10
- from claude_chain_runner import ChainAttempt, ChainInvocationOutcome
11
- from dev_env_scripts_constants.claude_chain_constants import TERMINAL_STATUS_SERVED
6
+
7
+ from dev_env_scripts_constants.account_broker_constants import JobOutcome, Product
12
8
 
13
9
  FIXTURE_ENCODING_KEYWORD_NAME = "encoding"
14
10
  FIXTURE_ERRORS_KEYWORD_NAME = "errors"
15
11
  FIXTURE_CHAIN_ENCODING = "utf-8"
16
12
  FIXTURE_CHAIN_ERRORS = "replace"
17
- FIXTURE_SERVED_COMMAND = "claude"
18
-
19
-
20
- def build_served_outcome(*, chain_stdout: str) -> ChainInvocationOutcome:
21
- return ChainInvocationOutcome(
22
- served_command=FIXTURE_SERVED_COMMAND,
23
- returncode=0,
24
- stdout=chain_stdout,
25
- stderr="",
26
- attempts=(ChainAttempt(command=FIXTURE_SERVED_COMMAND, status="served"),),
27
- terminal_status=TERMINAL_STATUS_SERVED,
28
- )
29
-
30
-
31
- def build_recording_runner(
32
- all_observed_runner_keywords: dict[str, object],
33
- *,
34
- chain_stdout: str,
35
- ) -> object:
36
- def _recording_runner(
37
- all_invocation_tokens: Sequence[str],
38
- *all_positionals: object,
39
- **all_keywords: object,
40
- ) -> subprocess.CompletedProcess[str]:
41
- del all_positionals
42
- all_observed_runner_keywords.update(all_keywords)
43
- return subprocess.CompletedProcess(
44
- args=list(all_invocation_tokens),
45
- returncode=0,
46
- stdout=chain_stdout,
47
- stderr="",
48
- )
49
-
50
- return _recording_runner
51
13
 
52
14
 
53
15
  def install_codec_seams(
54
16
  monkeypatch: pytest.MonkeyPatch,
55
17
  *,
56
- all_chain_codec_keywords: dict[str, str],
57
18
  chain_stdout: str,
58
19
  runner_host: object,
59
20
  runner_attribute_name: str,
60
21
  ) -> dict[str, object]:
61
- all_observed_runner_keywords: dict[str, object] = {}
22
+ observed_options: dict[str, object] = {}
62
23
 
63
- def _fake_claude(
64
- all_claude_arguments: list[str], *, timeout_seconds: int
65
- ) -> ChainInvocationOutcome:
66
- chain_runner.chain_subprocess_runner(
67
- [FIXTURE_SERVED_COMMAND, *all_claude_arguments],
68
- capture_output=True,
69
- text=True,
70
- timeout=timeout_seconds,
71
- check=False,
72
- **all_chain_codec_keywords,
73
- )
74
- return build_served_outcome(chain_stdout=chain_stdout)
24
+ def recording_runner(product: Product, argv: list[str], **options: object) -> JobOutcome:
25
+ assert product is Product.CLAUDE
26
+ assert argv[0] == "claude"
27
+ observed_options.update(options)
28
+ return JobOutcome(0, chain_stdout, "", "main", (("main", "served"),), "served", None, None)
75
29
 
76
- monkeypatch.setattr(
77
- chain_runner,
78
- "chain_subprocess_runner",
79
- build_recording_runner(
80
- all_observed_runner_keywords,
81
- chain_stdout=chain_stdout,
82
- ),
83
- )
84
- monkeypatch.setattr(runner_host, runner_attribute_name, _fake_claude)
85
- return all_observed_runner_keywords
30
+ monkeypatch.setattr(runner_host, runner_attribute_name, recording_runner)
31
+ return observed_options
@@ -2,19 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dev_env_scripts_constants.claude_account_constants import (
6
- CHOICE_MAIN,
7
- CHOICE_SECOND,
8
- CHOICE_WAIT,
9
- CLAUDE_BINARY_NAME,
10
- CREDENTIALS_FILE_NAME,
11
- EXTRA_PROFILES_FILE_NAME,
12
- JSON_ACCOUNT_KEY,
13
- JSON_CONFIG_DIRECTORY_KEY,
14
- JSON_REASON_KEY,
15
- MAIN_CLAUDE_HOME_DIRECTORY_NAME,
16
- )
17
- from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
5
+ from dev_env_scripts_constants.account_broker_constants import WAIT_EXIT_CODE
6
+ from dev_env_scripts_constants.claude_account_constants import CLAUDE_BINARY_NAME, JSON_ACCOUNT_KEY, JSON_REASON_KEY
18
7
 
19
8
  DEFAULT_PERMISSION_MODE: str = "auto"
20
9
 
@@ -22,14 +11,14 @@ DEFAULT_TIMEOUT_MINUTES: int = 60
22
11
 
23
12
  MINIMUM_TIMEOUT_MINUTES: int = 1
24
13
 
25
- WAIT_EXIT_CODE: int = 3
26
-
27
14
  TIMEOUT_EXIT_CODE: int = 124
28
15
 
29
16
  MISSING_BINARY_EXIT_CODE: int = 127
30
17
 
31
18
  LAUNCH_FAILURE_EXIT_CODE: int = 1
32
19
 
20
+ CONFIGURATION_FAILURE_EXIT_CODE: int = 2
21
+
33
22
  WAIT_DURATION_SECONDS: float = 0.0
34
23
 
35
24
  STDOUT_TAIL_CHARACTER_LIMIT: int = 4000
@@ -46,7 +35,7 @@ UTF8_ENCODING: str = "utf-8"
46
35
 
47
36
  UTF8_DECODE_ERRORS: str = "replace"
48
37
 
49
- CLI_DESCRIPTION: str = "Run one Claude worker using the account picker."
38
+ CLI_DESCRIPTION: str = "Run one Claude worker through the account broker."
50
39
 
51
40
  PROMPT_FILE_FLAG: str = "--prompt-file"
52
41
 
@@ -90,28 +79,21 @@ INVALID_TIMEOUT_MESSAGE: str = "--timeout-minutes must be at least 1"
90
79
 
91
80
 
92
81
  __all__ = [
93
- "CHOICE_MAIN",
94
- "CHOICE_SECOND",
95
- "CHOICE_WAIT",
96
82
  "CLAUDE_BINARY_NAME",
97
- "CLAUDE_CONFIG_DIR_ENV_VAR",
98
83
  "CLI_DESCRIPTION",
99
- "CREDENTIALS_FILE_NAME",
84
+ "CONFIGURATION_FAILURE_EXIT_CODE",
100
85
  "CWD_FLAG",
101
86
  "DEFAULT_PERMISSION_MODE",
102
87
  "DEFAULT_TIMEOUT_MINUTES",
103
88
  "DRAIN_ATTEMPT_LIMIT",
104
89
  "DRAIN_GRACE_TIMEOUT_SECONDS",
105
90
  "DURATION_DECIMAL_PLACES",
106
- "EXTRA_PROFILES_FILE_NAME",
107
91
  "INVALID_TIMEOUT_MESSAGE",
108
92
  "JSON_ACCOUNT_KEY",
109
- "JSON_CONFIG_DIRECTORY_KEY",
110
93
  "JSON_IS_ERROR_KEY",
111
94
  "JSON_REASON_KEY",
112
95
  "JSON_RESULT_KEY",
113
96
  "LAUNCH_FAILURE_EXIT_CODE",
114
- "MAIN_CLAUDE_HOME_DIRECTORY_NAME",
115
97
  "MISSING_BINARY_EXIT_CODE",
116
98
  "MINIMUM_TIMEOUT_MINUTES",
117
99
  "MODEL_FLAG",