@event4u/agent-config 1.41.1 → 2.0.0

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.
@@ -0,0 +1,226 @@
1
+ """``agent-config update`` — explicit, opt-in update of the version pin.
2
+
3
+ Phase 3 of road-to-portable-runtime-and-update-check (P3.1). The
4
+ command is the only user-driven path that flips
5
+ ``agent_config_version`` in ``.agent-settings.yml``; the daily banner
6
+ (P2) never writes settings files.
7
+
8
+ Flags:
9
+
10
+ * ``--check`` — print the available latest version + return; no write.
11
+ * ``--to <version>`` — pin to an exact version (registry-existence
12
+ checked). Downgrades are allowed; the pin is a project decision.
13
+ * (no flag) — pin to the registry's ``latest`` tag.
14
+
15
+ Write target: the **deepest** ``.agent-settings.yml`` in the project
16
+ cascade that already carries the ``agent_config_version`` key. When no
17
+ file carries it, the repo-root file is created/edited. Comments and
18
+ key ordering are preserved by line-based substitution.
19
+
20
+ The npx cache is warmed via
21
+ ``npx --yes @event4u/agent-config@<new> --version`` so the next
22
+ invocation is offline-fast. The P2 state file is refreshed in
23
+ lockstep — the new ``installed_version`` is recorded so the banner
24
+ does not yell about the old pin.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import re
31
+ import subprocess
32
+ import sys
33
+ import urllib.error
34
+ import urllib.request
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import Optional
38
+
39
+ from scripts._lib import update_check
40
+ from scripts._lib.agent_settings import (
41
+ DEFAULT_PROJECT_FILE,
42
+ _resolve_cascade_paths,
43
+ find_project_root,
44
+ )
45
+
46
+ PACKAGE_NAME = "@event4u/agent-config"
47
+ PIN_KEY = "agent_config_version"
48
+ REGISTRY_VERSION_URL = f"https://registry.npmjs.org/{PACKAGE_NAME}/{{version}}"
49
+ PIN_LINE_RE = re.compile(r"^(\s*agent_config_version\s*:\s*)(.*)$")
50
+
51
+
52
+ def _normalize(version: str) -> str:
53
+ return version.strip().lstrip("v")
54
+
55
+
56
+ def _registry_has_version(version: str, *, timeout: float = 1.0) -> bool:
57
+ url = REGISTRY_VERSION_URL.format(version=_normalize(version))
58
+ try:
59
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
60
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
61
+ return resp.status == 200
62
+ except (urllib.error.URLError, TimeoutError, OSError):
63
+ return False
64
+
65
+
66
+ def _find_pin_file(cwd: Path) -> Path:
67
+ """Return the deepest cascade file that carries the pin, else repo root."""
68
+ cascade = _resolve_cascade_paths(cwd, None)
69
+ for path in reversed(cascade):
70
+ if path.is_file() and _read_pin_line(path) is not None:
71
+ return path
72
+ # No file carries it — pick the repo-root cascade entry (shallowest).
73
+ if cascade:
74
+ return cascade[0]
75
+ return cwd / DEFAULT_PROJECT_FILE
76
+
77
+
78
+ def _read_pin_line(path: Path) -> Optional[int]:
79
+ try:
80
+ with path.open("r", encoding="utf-8") as fh:
81
+ for idx, line in enumerate(fh):
82
+ if PIN_LINE_RE.match(line):
83
+ return idx
84
+ except OSError:
85
+ return None
86
+ return None
87
+
88
+
89
+ def _write_pin(path: Path, new_version: str) -> bool:
90
+ """Rewrite the pin in ``path``; return ``True`` if the file changed."""
91
+ target = f'agent_config_version: "{_normalize(new_version)}"\n'
92
+ try:
93
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
94
+ except FileNotFoundError:
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+ path.write_text(target, encoding="utf-8")
97
+ return True
98
+ for idx, line in enumerate(lines):
99
+ if PIN_LINE_RE.match(line):
100
+ if lines[idx] == target:
101
+ return False
102
+ lines[idx] = target
103
+ path.write_text("".join(lines), encoding="utf-8")
104
+ return True
105
+ # File exists but has no pin line — append at end.
106
+ if lines and not lines[-1].endswith("\n"):
107
+ lines.append("\n")
108
+ lines.append(target)
109
+ path.write_text("".join(lines), encoding="utf-8")
110
+ return True
111
+
112
+
113
+ def _warm_npx_cache(version: str, *, runner=subprocess.run) -> None:
114
+ try:
115
+ runner(
116
+ ["npx", "--yes", f"{PACKAGE_NAME}@{_normalize(version)}", "--version"],
117
+ stdout=subprocess.DEVNULL,
118
+ stderr=subprocess.DEVNULL,
119
+ timeout=120,
120
+ check=False,
121
+ )
122
+ except (OSError, subprocess.TimeoutExpired):
123
+ pass
124
+
125
+
126
+ def _refresh_state(installed: str, latest: str, state_path: Path) -> None:
127
+ state = update_check._read_state(state_path)
128
+ payload = {
129
+ "last_check_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
130
+ "last_seen_version": latest,
131
+ "installed_version": installed,
132
+ }
133
+ state.update(payload)
134
+ try:
135
+ update_check._write_state(state_path, state)
136
+ except OSError:
137
+ pass
138
+
139
+
140
+ def main(
141
+ argv: Optional[list[str]] = None,
142
+ *,
143
+ cwd: Optional[Path] = None,
144
+ installed_version: Optional[str] = None,
145
+ fetcher=update_check.fetch_latest_from_npm,
146
+ version_checker=_registry_has_version,
147
+ cache_warmer=_warm_npx_cache,
148
+ state_path: Optional[Path] = None,
149
+ out=sys.stdout,
150
+ err=sys.stderr,
151
+ ) -> int:
152
+ """Entry point. ``scripts/agent-config`` dispatches here."""
153
+ parser = argparse.ArgumentParser(
154
+ prog="agent-config update",
155
+ description="Update the agent_config_version pin in .agent-settings.yml.",
156
+ )
157
+ parser.add_argument("--check", action="store_true",
158
+ help="Print the latest available version and exit. No file is written.")
159
+ parser.add_argument("--to", metavar="VERSION",
160
+ help="Pin to an explicit version (registry-existence checked).")
161
+ args = parser.parse_args(argv)
162
+
163
+ cwd = (cwd or Path.cwd()).resolve()
164
+ installed_version = installed_version or _detect_installed_version()
165
+ state_path = state_path or update_check.DEFAULT_STATE_PATH
166
+
167
+ if args.to:
168
+ target = _normalize(args.to)
169
+ if not version_checker(target):
170
+ print(
171
+ f"❌ agent-config: version {target} not found on the npm registry.",
172
+ file=err,
173
+ )
174
+ return 1
175
+ latest = target
176
+ else:
177
+ latest = fetcher()
178
+ if not latest:
179
+ print(
180
+ "❌ agent-config: failed to fetch latest version from the npm registry.",
181
+ file=err,
182
+ )
183
+ return 1
184
+ latest = _normalize(latest)
185
+
186
+ if args.check:
187
+ if update_check._is_newer(latest, installed_version):
188
+ print(f"agent-config {latest} available (you have {installed_version}).", file=out)
189
+ print(f"Update: npx {PACKAGE_NAME} update", file=out)
190
+ else:
191
+ print(f"agent-config is up to date ({installed_version}).", file=out)
192
+ return 0
193
+
194
+ pin_file = _find_pin_file(cwd)
195
+ changed = _write_pin(pin_file, latest)
196
+ try:
197
+ rel = pin_file.relative_to(cwd)
198
+ except ValueError:
199
+ rel = pin_file
200
+
201
+ if changed:
202
+ print(f"✅ Pinned {PACKAGE_NAME} to {latest} in {rel}.", file=out)
203
+ else:
204
+ print(f"ℹ️ {rel} already pins to {latest}.", file=out)
205
+
206
+ cache_warmer(latest)
207
+ _refresh_state(latest, latest, state_path)
208
+ return 0
209
+
210
+
211
+ def _detect_installed_version() -> str:
212
+ """Read ``version`` from the package's own ``package.json``."""
213
+ pkg_json = Path(__file__).resolve().parents[2] / "package.json"
214
+ try:
215
+ data = json.loads(pkg_json.read_text(encoding="utf-8"))
216
+ version = data.get("version")
217
+ if isinstance(version, str) and version.strip():
218
+ return version.strip()
219
+ except (OSError, ValueError, json.JSONDecodeError):
220
+ pass
221
+ return "0.0.0"
222
+
223
+
224
+ if __name__ == "__main__": # pragma: no cover
225
+ sys.exit(main())
226
+
@@ -3,17 +3,24 @@
3
3
  Phase 1 of road-to-portable-dev-preferences. Single source of truth for
4
4
  how scripts read agent settings — replaces ~15 ad-hoc loaders in P3.
5
5
 
6
- Resolution order (project wins, user-global fills gaps for whitelisted
7
- keys only):
6
+ Resolution order (deepest wins; user-global is whitelist-filtered only):
8
7
 
9
- 1. Project ``./.agent-settings.yml`` (full file, all keys)
10
- 2. ``~/.config/agent-config/agent-settings.yml`` (whitelist only)
11
- 3. Built-in defaults (currently empty)
8
+ N. ``~/.config/agent-config/agent-settings.yml`` (user-global; whitelist only)
9
+ N-1. ``<repo-root>/.agent-settings.yml`` (project-wide; all keys)
10
+ N-2. ``<intermediate-dir>/.agent-settings.yml`` (subsystem-scoped; all keys)
11
+ 1. ``<CWD>/.agent-settings.yml`` (deepest, wins; all keys)
12
+
13
+ ``<repo-root>`` is the nearest ancestor that contains ``.git`` (directory
14
+ **or** file — submodule support). The walk stops there — it never drifts
15
+ into a parent repo or ``$HOME``. When ``cwd`` is ``None`` (default), the
16
+ loader behaves identically to the pre-cascade contract: project file +
17
+ user-global only, no ancestor walk. Back-compat is hard.
12
18
 
13
19
  Whitelisted keys (``MERGEABLE_KEYS``) are exact dotted paths. A
14
20
  non-whitelisted key in the user-global file is silently ignored — the
15
21
  ``verbose=True`` flag surfaces ignored paths via ``logging.info`` for
16
- debugging.
22
+ debugging. Non-root in-project layers (intermediate + CWD) are **not**
23
+ whitelist-filtered — they live inside the project boundary.
17
24
 
18
25
  Contract — pure, read-only, tolerant:
19
26
 
@@ -28,7 +35,7 @@ from __future__ import annotations
28
35
 
29
36
  import logging
30
37
  from pathlib import Path
31
- from typing import Any
38
+ from typing import Any, Iterator
32
39
 
33
40
  logger = logging.getLogger(__name__)
34
41
 
@@ -53,10 +60,70 @@ MERGEABLE_KEYS: tuple[str, ...] = (
53
60
  _DEFAULTS: dict[str, Any] = {}
54
61
 
55
62
 
63
+ def find_project_root(start: Path) -> Path | None:
64
+ """Walk up from ``start`` looking for ``.git`` (file or directory).
65
+
66
+ Returns the first ancestor that contains ``.git`` as a file (submodule
67
+ pointer) or directory (regular checkout), or ``None`` if the walk
68
+ reaches the filesystem root without finding one. The walk stops at
69
+ the project boundary — it never drifts into a parent repo or
70
+ ``$HOME``.
71
+
72
+ Pure read-only; never touches the filesystem beyond ``exists()``
73
+ probes on the ``.git`` entry.
74
+ """
75
+ current = start.resolve() if start.exists() else start
76
+ # ``Path.parents`` excludes ``current`` itself, so probe it first.
77
+ for candidate in [current, *current.parents]:
78
+ git_marker = candidate / ".git"
79
+ if git_marker.exists():
80
+ return candidate
81
+ return None
82
+
83
+
84
+ def _resolve_cascade_paths(
85
+ cwd: Path | None,
86
+ project_path: Path | str | None,
87
+ ) -> list[Path]:
88
+ """Return the ordered cascade of in-project settings files (shallow → deep).
89
+
90
+ When ``cwd`` is provided and ``find_project_root(cwd)`` succeeds, the
91
+ list contains every ``<dir>/.agent-settings.yml`` from the repo root
92
+ down to ``cwd`` (inclusive on both ends), shallowest first. When
93
+ ``cwd`` is ``None`` or no ``.git`` is reached, falls back to the
94
+ single legacy project path — back-compat with the pre-cascade
95
+ loader.
96
+ """
97
+ if cwd is None:
98
+ legacy = Path(project_path) if project_path else Path(DEFAULT_PROJECT_FILE)
99
+ return [legacy]
100
+
101
+ root = find_project_root(cwd)
102
+ if root is None:
103
+ legacy = Path(project_path) if project_path else Path(DEFAULT_PROJECT_FILE)
104
+ return [legacy]
105
+
106
+ cwd_resolved = cwd.resolve()
107
+ # Build the chain root → … → cwd (shallowest first, deepest last).
108
+ chain: list[Path] = []
109
+ cursor = cwd_resolved
110
+ while True:
111
+ chain.append(cursor)
112
+ if cursor == root:
113
+ break
114
+ parent = cursor.parent
115
+ if parent == cursor:
116
+ break
117
+ cursor = parent
118
+ chain.reverse()
119
+ return [d / DEFAULT_PROJECT_FILE for d in chain]
120
+
121
+
56
122
  def load_agent_settings(
57
123
  project_path: Path | str | None = None,
58
124
  user_global_path: Path | str | None = None,
59
125
  verbose: bool = False,
126
+ cwd: Path | None = None,
60
127
  ) -> dict[str, Any]:
61
128
  """Return the merged settings dict.
62
129
 
@@ -65,10 +132,16 @@ def load_agent_settings(
65
132
  ``~/.config/agent-config/agent-settings.yml``. Both arguments accept
66
133
  ``Path`` or ``str``. Pass ``verbose=True`` to log keys present in
67
134
  user-global that are not on the whitelist.
135
+
136
+ ``cwd`` enables the in-project cascade: when provided **and**
137
+ ``find_project_root(cwd)`` reaches a ``.git`` ancestor, the loader
138
+ walks every ``.agent-settings.yml`` from the repo root down to
139
+ ``cwd`` and merges them shallowest → deepest (deepest wins).
140
+ Non-root layers are **not** whitelist-filtered (they live inside the
141
+ project boundary). When ``cwd`` is ``None`` (default), the loader
142
+ falls back to the single ``project_path`` behaviour — back-compat
143
+ with pre-cascade callers.
68
144
  """
69
- project = _read_yaml(
70
- Path(project_path) if project_path else Path(DEFAULT_PROJECT_FILE),
71
- ) or {}
72
145
  user_global_raw = _read_yaml(
73
146
  Path(user_global_path) if user_global_path else DEFAULT_USER_GLOBAL_FILE,
74
147
  ) or {}
@@ -82,12 +155,48 @@ def load_agent_settings(
82
155
  sorted(ignored),
83
156
  )
84
157
 
158
+ cascade = _resolve_cascade_paths(cwd, project_path)
159
+
85
160
  merged: dict[str, Any] = _deep_copy_defaults(_DEFAULTS)
86
161
  _deep_merge(merged, user_global_filtered)
87
- _deep_merge(merged, project)
162
+ for path in cascade:
163
+ layer = _read_yaml(path) or {}
164
+ if layer:
165
+ _deep_merge(merged, layer)
88
166
  return merged
89
167
 
90
168
 
169
+ def iter_setting_overrides(
170
+ project_path: Path | str | None = None,
171
+ user_global_path: Path | str | None = None,
172
+ cwd: Path | None = None,
173
+ ) -> Iterator[tuple[str, Any, Path]]:
174
+ """Yield ``(dotted_key, value, source_path)`` for every leaf setting.
175
+
176
+ Walks the same cascade as :func:`load_agent_settings` and emits one
177
+ tuple per leaf observed at each layer (user-global → repo-root →
178
+ intermediates → CWD). Callers can detect overrides by grouping
179
+ tuples on ``dotted_key`` — the deepest tuple per group wins. Useful
180
+ for ``task settings:trace`` and other banner-only diagnostics.
181
+ Never blocks, never raises on missing files.
182
+ """
183
+ user_global_path_resolved = (
184
+ Path(user_global_path) if user_global_path else DEFAULT_USER_GLOBAL_FILE
185
+ )
186
+ user_global_raw = _read_yaml(user_global_path_resolved) or {}
187
+ user_global_filtered, _ = _filter_whitelist(user_global_raw, MERGEABLE_KEYS)
188
+ if user_global_filtered:
189
+ for key in _leaf_paths(user_global_filtered):
190
+ yield key, _get_dotted(user_global_filtered, key), user_global_path_resolved
191
+
192
+ for path in _resolve_cascade_paths(cwd, project_path):
193
+ layer = _read_yaml(path)
194
+ if not layer:
195
+ continue
196
+ for key in _leaf_paths(layer):
197
+ yield key, _get_dotted(layer, key), path
198
+
199
+
91
200
  def _read_yaml(path: Path) -> dict[str, Any] | None:
92
201
  """Best-effort YAML read; never raises. Returns ``None`` on any failure."""
93
202
  if not path.is_file():
@@ -0,0 +1,109 @@
1
+ """Resolve files under ``agents/<kind>/<name>.md`` via the cascade.
2
+
3
+ Phase 1 of road-to-portable-runtime-and-update-check. Companion to
4
+ ``agent_settings.py``: where the settings loader merges YAML, this
5
+ resolves single-file overlays — overrides, contexts, decisions — to a
6
+ single deepest match across the in-project ancestor chain plus the
7
+ user-global directory (when the ``kind`` is whitelisted).
8
+
9
+ Resolution order (deepest wins, every layer optional):
10
+
11
+ N. ``~/.config/agent-config/agents/<kind>/<name>.md`` (user-global; weakest;
12
+ ``kind`` must be in
13
+ ``USER_GLOBAL_OVERLAY_KINDS``)
14
+ N-1. ``<repo-root>/agents/<kind>/<name>.md``
15
+ N-2. ``<intermediate-dir>/agents/<kind>/<name>.md`` (optional)
16
+ 1. ``<CWD>/agents/<kind>/<name>.md`` (deepest, wins)
17
+
18
+ Asymmetry: ``overrides/`` is the developer's personal layer and may
19
+ live user-global; ``contexts/`` and ``decisions/`` are project-shaped
20
+ and must not leak across projects, so the user-global layer is
21
+ silently skipped for them. Stateful subdirs (``state/``, ``memory/``,
22
+ ``roadmaps/``, ``work_engine/``, ``council-*/``) are not cascade-eligible
23
+ at all and raise ``ValueError`` when passed as ``kind``.
24
+
25
+ Contract — pure, read-only, tolerant:
26
+
27
+ * Does not read file contents — returns the resolved ``Path`` only.
28
+ * Missing layer / missing file → silently skipped, never raises.
29
+ * Invalid ``kind`` → ``ValueError`` (programmer error, not user input).
30
+ * No file is ever created or written by this module.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ from pathlib import Path
36
+
37
+ from scripts._lib.agent_settings import find_project_root
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ #: Subdirs of ``agents/`` that participate in the cascade. Every entry
42
+ #: is **additive** (single-file artefacts; deepest wins). Stateful or
43
+ #: session-scoped subdirs (``state/``, ``memory/``, ``roadmaps/``,
44
+ #: ``work_engine/``, ``.agent-prices.md``, ``council-*/``) are
45
+ #: deliberately excluded — they are project-rooted only.
46
+ CASCADE_ELIGIBLE_KINDS: frozenset[str] = frozenset({
47
+ "overrides",
48
+ "contexts",
49
+ "decisions",
50
+ })
51
+
52
+ #: Subset of :data:`CASCADE_ELIGIBLE_KINDS` allowed to live at the
53
+ #: user-global layer (``~/.config/agent-config/agents/<kind>/``).
54
+ #: ``contexts/`` and ``decisions/`` are project-shaped and must not leak
55
+ #: across projects; only ``overrides/`` — the developer's personal
56
+ #: layer — is whitelisted.
57
+ USER_GLOBAL_OVERLAY_KINDS: frozenset[str] = frozenset({"overrides"})
58
+
59
+ USER_GLOBAL_AGENTS_DIR = Path.home() / ".config" / "agent-config" / "agents"
60
+
61
+
62
+ def resolve_overlay(name: str, kind: str, cwd: Path) -> Path | None:
63
+ """Return the deepest existing ``agents/<kind>/<name>.md`` or ``None``.
64
+
65
+ Walks the in-project ancestor chain from ``cwd`` to the ``.git``
66
+ repo root (inclusive) and probes each layer for
67
+ ``agents/<kind>/<name>.md``. Falls through to the user-global
68
+ directory only when ``kind in USER_GLOBAL_OVERLAY_KINDS``. Returns
69
+ the **deepest** existing file (highest precedence), or ``None`` if
70
+ no layer carries the overlay.
71
+
72
+ ``name`` is treated as a basename — no path traversal, no
73
+ subdirectories. Callers that need nested layouts should encode the
74
+ structure inside the overlay file, not the filename.
75
+ """
76
+ if kind not in CASCADE_ELIGIBLE_KINDS:
77
+ raise ValueError(
78
+ f"agents_overlay: kind {kind!r} not cascade-eligible "
79
+ f"(allowed: {sorted(CASCADE_ELIGIBLE_KINDS)})",
80
+ )
81
+
82
+ # Candidate layers, shallowest → deepest. Last match wins.
83
+ candidates: list[Path] = []
84
+
85
+ if kind in USER_GLOBAL_OVERLAY_KINDS:
86
+ candidates.append(USER_GLOBAL_AGENTS_DIR / kind / f"{name}.md")
87
+
88
+ root = find_project_root(cwd)
89
+ if root is not None:
90
+ cwd_resolved = cwd.resolve()
91
+ chain: list[Path] = []
92
+ cursor = cwd_resolved
93
+ while True:
94
+ chain.append(cursor)
95
+ if cursor == root:
96
+ break
97
+ parent = cursor.parent
98
+ if parent == cursor:
99
+ break
100
+ cursor = parent
101
+ chain.reverse()
102
+ for layer_dir in chain:
103
+ candidates.append(layer_dir / "agents" / kind / f"{name}.md")
104
+
105
+ deepest: Path | None = None
106
+ for candidate in candidates:
107
+ if candidate.is_file():
108
+ deepest = candidate
109
+ return deepest
@@ -0,0 +1,152 @@
1
+ """Pin-aware version resolver for the ``agent-config`` dispatcher.
2
+
3
+ Phase 3 of road-to-portable-runtime-and-update-check (P3.2). The
4
+ dispatcher consults this module **before** doing any work. If
5
+ ``.agent-settings.yml`` carries a non-empty ``agent_config_version``
6
+ pin and the currently running package version does not match, the
7
+ process re-execs via ``npx @event4u/agent-config@<pin> <argv>``.
8
+
9
+ Determinism is the goal: a consumer's ``npx`` cache may resolve to a
10
+ different version than the project pinned to, and the resolver
11
+ guarantees the pinned version is the one that actually runs.
12
+
13
+ Escape hatch: ``AGENT_CONFIG_NO_PIN_REEXEC=1`` disables the re-exec
14
+ entirely (used for local development of the package itself and for
15
+ the recursion guard described below).
16
+
17
+ Recursion guard: the parent sets ``AGENT_CONFIG_PIN_REEXEC_DEPTH=1``
18
+ on the child env so the re-exec'd child does not loop if the freshly
19
+ spawned ``npx`` resolves to a still-mismatched version. One re-exec
20
+ per process, full stop.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import shutil
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ PACKAGE_NAME = "@event4u/agent-config"
31
+ PIN_KEY = "agent_config_version"
32
+ NO_REEXEC_ENV = "AGENT_CONFIG_NO_PIN_REEXEC"
33
+ REEXEC_DEPTH_ENV = "AGENT_CONFIG_PIN_REEXEC_DEPTH"
34
+
35
+
36
+ def _normalize(version: str) -> str:
37
+ return version.strip().lstrip("v")
38
+
39
+
40
+ def read_pin(cwd: Path, *, settings_loader=None) -> Optional[str]:
41
+ """Return the pinned version from the cascaded settings, or ``None``.
42
+
43
+ Empty string and missing key both yield ``None``.
44
+ """
45
+ if settings_loader is None:
46
+ from scripts._lib import agent_settings # local import (test override)
47
+
48
+ settings_loader = agent_settings.load_agent_settings
49
+
50
+ try:
51
+ settings = settings_loader(cwd=cwd)
52
+ except Exception:
53
+ return None
54
+ raw = settings.get(PIN_KEY)
55
+ if not isinstance(raw, str):
56
+ return None
57
+ pin = raw.strip()
58
+ return pin or None
59
+
60
+
61
+ def should_reexec(
62
+ pin: Optional[str],
63
+ installed: str,
64
+ *,
65
+ env: Optional[dict] = None,
66
+ ) -> bool:
67
+ """Pure predicate: do we need to re-exec under the pinned version?"""
68
+ env = env if env is not None else os.environ
69
+ if env.get(NO_REEXEC_ENV) == "1":
70
+ return False
71
+ if env.get(REEXEC_DEPTH_ENV) == "1":
72
+ return False
73
+ if not pin:
74
+ return False
75
+ if not installed:
76
+ return False
77
+ return _normalize(pin) != _normalize(installed)
78
+
79
+
80
+ def build_reexec_argv(pin: str, argv: list[str]) -> list[str]:
81
+ """Build the ``npx`` argv that re-execs at the pinned version."""
82
+ return ["npx", "--yes", f"{PACKAGE_NAME}@{_normalize(pin)}", *argv]
83
+
84
+
85
+ def maybe_reexec(
86
+ installed: str,
87
+ *,
88
+ cwd: Optional[Path] = None,
89
+ argv: Optional[list[str]] = None,
90
+ env: Optional[dict] = None,
91
+ runner=None,
92
+ ) -> Optional[int]:
93
+ """Re-exec at the pinned version if needed; return the child exit code.
94
+
95
+ Returns ``None`` when no re-exec is performed (caller continues).
96
+ The injected ``runner`` covers the test path — defaults to
97
+ :func:`os.execvpe` on real invocations.
98
+ """
99
+ cwd = cwd or Path.cwd()
100
+ argv = argv if argv is not None else sys.argv
101
+ env = env if env is not None else os.environ
102
+
103
+ pin = read_pin(cwd)
104
+ if not should_reexec(pin, installed, env=env):
105
+ return None
106
+
107
+ assert pin is not None # narrowed by should_reexec
108
+ npx = shutil.which("npx")
109
+ if not npx:
110
+ # Cannot re-exec without npx — silently fall back to running
111
+ # the locally-installed version. Better to do something than
112
+ # to die because of a missing CLI.
113
+ return None
114
+
115
+ new_argv = build_reexec_argv(pin, argv[1:] if argv else [])
116
+ child_env = dict(env)
117
+ child_env[REEXEC_DEPTH_ENV] = "1"
118
+
119
+ if runner is None:
120
+ # Replace the current process; never returns on success.
121
+ os.execvpe(npx, new_argv, child_env)
122
+ return 1 # unreachable on POSIX
123
+ return runner(npx, new_argv, child_env)
124
+
125
+
126
+ def _parse_cli(argv: list[str]) -> tuple[Path, str, list[str]]:
127
+ """Parse the dispatcher-facing argv: ``--cwd X --installed Y -- ARGS``."""
128
+ cwd = Path.cwd()
129
+ installed = ""
130
+ forward: list[str] = []
131
+ i = 0
132
+ while i < len(argv):
133
+ token = argv[i]
134
+ if token == "--cwd" and i + 1 < len(argv):
135
+ cwd = Path(argv[i + 1])
136
+ i += 2
137
+ elif token == "--installed" and i + 1 < len(argv):
138
+ installed = argv[i + 1]
139
+ i += 2
140
+ elif token == "--":
141
+ forward = argv[i + 1:]
142
+ break
143
+ else:
144
+ i += 1
145
+ return cwd, installed, forward
146
+
147
+
148
+ if __name__ == "__main__": # pragma: no cover
149
+ cwd, installed, forward = _parse_cli(sys.argv[1:])
150
+ # Build the argv the child should see: ``agent-config <forward...>``.
151
+ child_argv = ["agent-config", *forward]
152
+ maybe_reexec(installed, cwd=cwd, argv=child_argv)