@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.
- package/.agent-src/templates/agents/agent-project-settings.example.yml +14 -0
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +120 -11
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +31 -0
- package/README.md +34 -25
- package/config/agent-settings.template.yml +25 -0
- package/docs/architecture.md +46 -0
- package/docs/customization.md +125 -9
- package/docs/installation.md +9 -36
- package/docs/migration/v1-to-v2.md +98 -0
- package/docs/setup/per-ide/claude-code.md +0 -17
- package/docs/setup/per-ide/claude-desktop.md +35 -48
- package/docs/setup/per-ide/windsurf.md +0 -11
- package/package.json +1 -6
- package/scripts/_cli/__init__.py +0 -0
- package/scripts/_cli/cmd_migrate.py +270 -0
- package/scripts/_cli/cmd_update.py +226 -0
- package/scripts/_lib/agent_settings.py +120 -11
- package/scripts/_lib/agents_overlay.py +109 -0
- package/scripts/_lib/pin_resolver.py +152 -0
- package/scripts/_lib/update_check.py +183 -0
- package/scripts/agent-config +73 -1
- package/scripts/check_overlay_cascade_subdirs.py +125 -0
- package/scripts/check_template_pin_drift.py +112 -0
- package/scripts/check_update_banner.py +86 -0
- package/scripts/install +2 -37
- package/scripts/install.py +6 -207
- package/bin/install.php +0 -45
- package/composer.json +0 -33
- package/scripts/postinstall.sh +0 -76
- package/templates/global-install-manifest.yml +0 -91
|
@@ -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 (
|
|
7
|
-
keys only):
|
|
6
|
+
Resolution order (deepest wins; user-global is whitelist-filtered only):
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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)
|