@event4u/agent-config 2.3.0 → 2.4.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.
Files changed (39) hide show
  1. package/.agent-src/commands/onboard.md +14 -9
  2. package/.agent-src/skills/ai-council/SKILL.md +5 -3
  3. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -1
  4. package/.agent-src/templates/agents/agent-project-settings.example.yml +4 -3
  5. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +29 -7
  6. package/.agent-src/templates/scripts/work_engine/_lib/user_global_paths.py +249 -0
  7. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +8 -5
  8. package/.claude-plugin/marketplace.json +1 -1
  9. package/CHANGELOG.md +39 -0
  10. package/config/agent-settings.template.yml +5 -3
  11. package/docs/catalog.md +5 -3
  12. package/docs/contracts/installed-tools-lockfile.md +4 -0
  13. package/docs/customization.md +23 -17
  14. package/docs/decisions/ADR-007-agent-discovery-scopes.md +6 -0
  15. package/docs/decisions/ADR-009-event4u-namespace.md +188 -0
  16. package/docs/decisions/INDEX.md +1 -0
  17. package/docs/guidelines/agent-infra/installed-tools-manifest.md +1 -1
  18. package/docs/guidelines/agent-infra/layered-settings.md +6 -4
  19. package/docs/installation.md +3 -2
  20. package/docs/migration/v1-to-v2.md +45 -0
  21. package/docs/setup/per-ide/claude-desktop.md +107 -65
  22. package/package.json +1 -1
  23. package/scripts/_cli/cmd_uninstall.py +17 -7
  24. package/scripts/_cli/cmd_update.py +11 -7
  25. package/scripts/_lib/agent_settings.py +29 -7
  26. package/scripts/_lib/agents_overlay.py +15 -4
  27. package/scripts/_lib/claude_desktop_bundler.py +150 -0
  28. package/scripts/_lib/installed_lock.py +56 -4
  29. package/scripts/_lib/installed_tools.py +1 -1
  30. package/scripts/_lib/update_check.py +29 -5
  31. package/scripts/_lib/user_global_paths.py +249 -0
  32. package/scripts/ai_council/__init__.py +4 -3
  33. package/scripts/ai_council/budget_guard.py +34 -4
  34. package/scripts/ai_council/bundler.py +2 -0
  35. package/scripts/ai_council/clients.py +28 -7
  36. package/scripts/install.py +149 -49
  37. package/scripts/install_anthropic_key.sh +5 -3
  38. package/scripts/install_openai_key.sh +5 -3
  39. package/scripts/skill_trigger_eval.py +13 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "Shared agent configuration \u2014 skills, rules, commands, guidelines, and templates for AI coding tools",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -302,7 +302,7 @@ def _parse(argv: list[str]) -> argparse.Namespace:
302
302
  ),
303
303
  )
304
304
  parser.add_argument("--global", dest="global_mode", action="store_true",
305
- help="operate on user-scope lockfile (~/.config/agent-config/installed.lock)")
305
+ help="operate on user-scope lockfile (~/.event4u/agent-config/installed.lock; legacy ~/.config/agent-config/installed.lock read as fallback)")
306
306
  parser.add_argument("--tools", default=None,
307
307
  help="comma-separated tool IDs to uninstall (default: all in lockfile)")
308
308
  parser.add_argument("--project", default=None, help="project root (default: cwd)")
@@ -422,6 +422,7 @@ def _uninstall_project(opts: argparse.Namespace) -> int:
422
422
 
423
423
  def _uninstall_global(opts: argparse.Namespace) -> int:
424
424
  lock_path = installed_lock.lockfile_path()
425
+ write_path = installed_lock.lockfile_write_path()
425
426
  lock = installed_lock.read_lockfile(lock_path)
426
427
  if lock is None and not opts.force:
427
428
  print(f"❌ no global lockfile at {lock_path}", file=sys.stderr)
@@ -442,15 +443,24 @@ def _uninstall_global(opts: argparse.Namespace) -> int:
442
443
  removed_names.append(tool)
443
444
  if lock is not None and not opts.dry_run:
444
445
  remaining = [t for t in lock.get("tools", []) if t not in tools]
446
+ version = lock.get("agent_config_version", "")
445
447
  if remaining:
446
- installed_lock.write_lockfile(remaining, version=lock.get("agent_config_version", ""))
448
+ installed_lock.write_lockfile(version, remaining, path=write_path)
449
+ # Drop the legacy file if it differs from the canonical write
450
+ # target so the namespace migration completes on uninstall.
451
+ if lock_path != write_path:
452
+ try:
453
+ lock_path.unlink()
454
+ except OSError:
455
+ pass
447
456
  print(f"✅ lockfile updated ({len(tools)} entries removed, {len(remaining)} kept)")
448
457
  else:
449
- try:
450
- lock_path.unlink()
451
- print(f"✅ lockfile deleted ({lock_path})")
452
- except OSError as exc:
453
- print(f"⚠️ could not delete lockfile: {exc}")
458
+ for target in {lock_path, write_path}:
459
+ try:
460
+ target.unlink()
461
+ except OSError:
462
+ pass
463
+ print(f"✅ lockfile deleted ({write_path})")
454
464
  return 0
455
465
 
456
466
 
@@ -233,24 +233,28 @@ def main(
233
233
 
234
234
 
235
235
  def _refresh_global_lockfile(version: str, *, out=sys.stdout) -> None:
236
- """Update ``~/.config/agent-config/installed.lock`` if it exists.
236
+ """Update the global ``installed.lock`` if it exists.
237
+
238
+ Resolution prefers ``~/.event4u/agent-config/installed.lock`` and
239
+ falls back to the legacy ``~/.config/agent-config/installed.lock``.
237
240
 
238
241
  Phase 1.6 — the lockfile is only present when the user has run a
239
242
  global install; we never create one here, but we keep it in lockstep
240
243
  when ``update`` flips the pin. Atomic write goes through
241
244
  ``installed_lock.write_lockfile``.
242
245
  """
243
- lock_path = installed_lock.lockfile_path()
244
- existing = installed_lock.read_lockfile(path=lock_path)
246
+ read_path = installed_lock.lockfile_path()
247
+ write_path = installed_lock.lockfile_write_path()
248
+ existing = installed_lock.read_lockfile(path=read_path)
245
249
  if existing is None:
246
250
  return
247
251
  recorded = existing.get("agent_config_version")
248
252
  tools = list(existing.get("tools", []))
249
- if recorded == version:
250
- print(f"ℹ️ {lock_path} already records {version}.", file=out)
253
+ if recorded == version and read_path == write_path:
254
+ print(f"ℹ️ {write_path} already records {version}.", file=out)
251
255
  return
252
- installed_lock.write_lockfile(version, tools, path=lock_path)
253
- print(f"✅ Refreshed global lockfile at {lock_path}.", file=out)
256
+ installed_lock.write_lockfile(version, tools, path=write_path)
257
+ print(f"✅ Refreshed global lockfile at {write_path}.", file=out)
254
258
 
255
259
 
256
260
  def _detect_installed_version() -> str:
@@ -5,11 +5,18 @@ how scripts read agent settings — replaces ~15 ad-hoc loaders in P3.
5
5
 
6
6
  Resolution order (deepest wins; user-global is whitelist-filtered only):
7
7
 
8
- N. ``~/.config/agent-config/agent-settings.yml`` (user-global; whitelist only)
8
+ N. ``~/.event4u/agent-config/agent-settings.yml`` (user-global; whitelist only)
9
9
  N-1. ``<repo-root>/.agent-settings.yml`` (project-wide; all keys)
10
10
  N-2. ``<intermediate-dir>/.agent-settings.yml`` (subsystem-scoped; all keys)
11
11
  1. ``<CWD>/.agent-settings.yml`` (deepest, wins; all keys)
12
12
 
13
+ The user-global path is resolved via the sibling
14
+ :mod:`work_engine._lib.user_global_paths` module (vendored from
15
+ ``scripts/_lib/user_global_paths.py`` so the engine stays self-contained
16
+ when shipped into consumer projects) with a read-fallback to the legacy
17
+ ``~/.config/agent-config/agent-settings.yml`` so pre-2.4 installs keep
18
+ working during the namespace migration.
19
+
13
20
  ``<repo-root>`` is the nearest ancestor that contains ``.git`` (directory
14
21
  **or** file — submodule support). The walk stops there — it never drifts
15
22
  into a parent repo or ``$HOME``. When ``cwd`` is ``None`` (default), the
@@ -37,12 +44,26 @@ import logging
37
44
  from pathlib import Path
38
45
  from typing import Any, Iterator
39
46
 
47
+ from . import user_global_paths
48
+
40
49
  logger = logging.getLogger(__name__)
41
50
 
42
51
  DEFAULT_PROJECT_FILE = ".agent-settings.yml"
43
- DEFAULT_USER_GLOBAL_FILE = (
44
- Path.home() / ".config" / "agent-config" / "agent-settings.yml"
45
- )
52
+ USER_GLOBAL_FILENAME = "agent-settings.yml"
53
+
54
+ #: Canonical write target under the new ``~/.event4u/agent-config/``
55
+ #: namespace. Reads route through :func:`_resolve_user_global_file` so
56
+ #: pre-2.4 installs are still picked up from ``~/.config/agent-config/``
57
+ #: until the migration shim copies them across.
58
+ DEFAULT_USER_GLOBAL_FILE = user_global_paths.write_target(USER_GLOBAL_FILENAME)
59
+
60
+
61
+ def _resolve_user_global_file() -> Path:
62
+ """Return the active user-global settings path with legacy fallback."""
63
+ found = user_global_paths.resolve_with_fallback(USER_GLOBAL_FILENAME)
64
+ if found is not None:
65
+ return found
66
+ return DEFAULT_USER_GLOBAL_FILE
46
67
 
47
68
  #: Exact dotted paths allowed to cascade from user-global into the merged
48
69
  #: settings. Anything not listed here is silently ignored when present in
@@ -129,7 +150,8 @@ def load_agent_settings(
129
150
 
130
151
  ``project_path`` defaults to ``./.agent-settings.yml`` (CWD-relative).
131
152
  ``user_global_path`` defaults to
132
- ``~/.config/agent-config/agent-settings.yml``. Both arguments accept
153
+ ``~/.event4u/agent-config/agent-settings.yml`` (with a read fallback
154
+ to the legacy ``~/.config/agent-config/agent-settings.yml``). Both arguments accept
133
155
  ``Path`` or ``str``. Pass ``verbose=True`` to log keys present in
134
156
  user-global that are not on the whitelist.
135
157
 
@@ -143,7 +165,7 @@ def load_agent_settings(
143
165
  with pre-cascade callers.
144
166
  """
145
167
  user_global_raw = _read_yaml(
146
- Path(user_global_path) if user_global_path else DEFAULT_USER_GLOBAL_FILE,
168
+ Path(user_global_path) if user_global_path else _resolve_user_global_file(),
147
169
  ) or {}
148
170
 
149
171
  user_global_filtered, ignored = _filter_whitelist(
@@ -181,7 +203,7 @@ def iter_setting_overrides(
181
203
  Never blocks, never raises on missing files.
182
204
  """
183
205
  user_global_path_resolved = (
184
- Path(user_global_path) if user_global_path else DEFAULT_USER_GLOBAL_FILE
206
+ Path(user_global_path) if user_global_path else _resolve_user_global_file()
185
207
  )
186
208
  user_global_raw = _read_yaml(user_global_path_resolved) or {}
187
209
  user_global_filtered, _ = _filter_whitelist(user_global_raw, MERGEABLE_KEYS)
@@ -8,9 +8,12 @@ user-global directory (when the ``kind`` is whitelisted).
8
8
 
9
9
  Resolution order (deepest wins, every layer optional):
10
10
 
11
- N. ``~/.config/agent-config/agents/<kind>/<name>.md`` (user-global; weakest;
11
+ N. ``~/.event4u/agent-config/agents/<kind>/<name>.md`` (user-global; weakest;
12
12
  ``kind`` must be in
13
- ``USER_GLOBAL_OVERLAY_KINDS``)
13
+ ``USER_GLOBAL_OVERLAY_KINDS``;
14
+ legacy
15
+ ``~/.config/agent-config/agents/``
16
+ tree read as fallback)
14
17
  N-1. ``<repo-root>/agents/<kind>/<name>.md``
15
18
  N-2. ``<intermediate-dir>/agents/<kind>/<name>.md`` (optional)
16
19
  1. ``<CWD>/agents/<kind>/<name>.md`` (deepest, wins)
@@ -34,6 +37,7 @@ from __future__ import annotations
34
37
  import logging
35
38
  from pathlib import Path
36
39
 
40
+ from scripts._lib import user_global_paths
37
41
  from scripts._lib.agent_settings import find_project_root
38
42
 
39
43
  logger = logging.getLogger(__name__)
@@ -50,13 +54,17 @@ CASCADE_ELIGIBLE_KINDS: frozenset[str] = frozenset({
50
54
  })
51
55
 
52
56
  #: Subset of :data:`CASCADE_ELIGIBLE_KINDS` allowed to live at the
53
- #: user-global layer (``~/.config/agent-config/agents/<kind>/``).
57
+ #: user-global layer (``~/.event4u/agent-config/agents/<kind>/``).
54
58
  #: ``contexts/`` and ``decisions/`` are project-shaped and must not leak
55
59
  #: across projects; only ``overrides/`` — the developer's personal
56
60
  #: layer — is whitelisted.
57
61
  USER_GLOBAL_OVERLAY_KINDS: frozenset[str] = frozenset({"overrides"})
58
62
 
59
- USER_GLOBAL_AGENTS_DIR = Path.home() / ".config" / "agent-config" / "agents"
63
+ #: Canonical write target under the new vendor namespace. The probe in
64
+ #: :func:`resolve_overlay` adds the legacy ``~/.config/agent-config/agents/``
65
+ #: tree as a read-only fallback for pre-2.4 installs.
66
+ USER_GLOBAL_AGENTS_DIR = user_global_paths.write_target("agents")
67
+ _LEGACY_USER_GLOBAL_AGENTS_DIR = user_global_paths.legacy_xdg_root() / "agents"
60
68
 
61
69
 
62
70
  def resolve_overlay(name: str, kind: str, cwd: Path) -> Path | None:
@@ -83,6 +91,9 @@ def resolve_overlay(name: str, kind: str, cwd: Path) -> Path | None:
83
91
  candidates: list[Path] = []
84
92
 
85
93
  if kind in USER_GLOBAL_OVERLAY_KINDS:
94
+ # Legacy first, new last — deepest wins, so the new namespace
95
+ # overrides the legacy path when both happen to exist mid-migration.
96
+ candidates.append(_LEGACY_USER_GLOBAL_AGENTS_DIR / kind / f"{name}.md")
86
97
  candidates.append(USER_GLOBAL_AGENTS_DIR / kind / f"{name}.md")
87
98
 
88
99
  root = find_project_root(cwd)
@@ -0,0 +1,150 @@
1
+ """Claude Desktop skill ZIP bundler (Phase 4 of event4u-namespace roadmap).
2
+
3
+ Claude Desktop has no filesystem convention for skills; the Customize →
4
+ Skills UI accepts a ZIP per skill via the Upload button. This module
5
+ walks ``<package_root>/.agent-src/skills/*`` and produces one
6
+ ``<skill-name>.zip`` per directory into ``dest_dir``.
7
+
8
+ Contract:
9
+
10
+ - Each ZIP contains ``SKILL.md`` plus every sibling file under the same
11
+ directory (recursive). Symlinks are dereferenced so the ZIP is
12
+ self-contained.
13
+ - Exclusions: ``.git*``, ``__pycache__``, ``*.pyc`` — matched on the
14
+ basename of any path component.
15
+ - A skill folder without a ``SKILL.md`` is skipped (defensive: avoids
16
+ shipping Claude-Code orchestrator stubs that don't follow the
17
+ Anthropic skill schema).
18
+ - Writes are atomic via tempfile → ``os.replace``.
19
+ - Idempotent: each ZIP gets a sibling ``<skill-name>.sha256`` recording
20
+ the manifest digest. If the recomputed digest matches the recorded
21
+ one, the existing ZIP is left untouched (unless ``force=True``).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import os
27
+ import tempfile
28
+ import zipfile
29
+ from pathlib import Path
30
+ from typing import Iterable, Optional
31
+
32
+ #: Filenames or path components that are never included in a bundle.
33
+ _EXCLUDED_BASENAMES = frozenset({"__pycache__", ".DS_Store"})
34
+ _EXCLUDED_PREFIXES = (".git",)
35
+ _EXCLUDED_SUFFIXES = (".pyc", ".pyo")
36
+
37
+
38
+ def _is_excluded(rel_parts: tuple[str, ...]) -> bool:
39
+ """Return True if any component matches the exclusion lists."""
40
+ for part in rel_parts:
41
+ if part in _EXCLUDED_BASENAMES:
42
+ return True
43
+ if part.startswith(_EXCLUDED_PREFIXES):
44
+ return True
45
+ if part.endswith(_EXCLUDED_SUFFIXES):
46
+ return True
47
+ return False
48
+
49
+
50
+ def _walk_skill_files(skill_dir: Path) -> list[tuple[Path, tuple[str, ...]]]:
51
+ """Return ``[(abs_path, rel_parts), ...]`` for every file in the skill.
52
+
53
+ Symlinks are followed (``os.walk(..., followlinks=True)``) so a
54
+ bundle from a symlinked entry under ``.agent-src/skills/`` contains
55
+ the actual target content, not a dangling symlink.
56
+ """
57
+ out: list[tuple[Path, tuple[str, ...]]] = []
58
+ resolved = skill_dir.resolve()
59
+ for root, dirs, files in os.walk(resolved, followlinks=True):
60
+ root_path = Path(root)
61
+ rel_root = root_path.relative_to(resolved)
62
+ # Prune excluded dirs in-place so os.walk skips them.
63
+ dirs[:] = [d for d in dirs if not _is_excluded((d,))]
64
+ for fname in files:
65
+ rel_parts = (*rel_root.parts, fname) if rel_root.parts else (fname,)
66
+ if _is_excluded(rel_parts):
67
+ continue
68
+ out.append((root_path / fname, rel_parts))
69
+ out.sort(key=lambda item: item[1])
70
+ return out
71
+
72
+
73
+ def _manifest_digest(files: Iterable[tuple[Path, tuple[str, ...]]]) -> str:
74
+ """Hash sorted (rel_path, content_sha256) pairs into one digest.
75
+
76
+ Stable across runs as long as the input set + bytes are stable. Used
77
+ as the idempotency token written to ``<skill>.sha256``.
78
+ """
79
+ h = hashlib.sha256()
80
+ for abs_path, rel_parts in files:
81
+ rel = "/".join(rel_parts)
82
+ h.update(rel.encode("utf-8"))
83
+ h.update(b"\x00")
84
+ h.update(hashlib.sha256(abs_path.read_bytes()).digest())
85
+ h.update(b"\x00")
86
+ return h.hexdigest()
87
+
88
+
89
+ def _atomic_write_zip(
90
+ zip_path: Path, files: list[tuple[Path, tuple[str, ...]]]
91
+ ) -> None:
92
+ """Write ``files`` into ``zip_path`` atomically (temp + rename)."""
93
+ zip_path.parent.mkdir(parents=True, exist_ok=True)
94
+ fd, tmp_name = tempfile.mkstemp(
95
+ prefix=f".{zip_path.stem}.", suffix=".zip.tmp", dir=str(zip_path.parent),
96
+ )
97
+ os.close(fd)
98
+ tmp_path = Path(tmp_name)
99
+ try:
100
+ with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
101
+ for abs_path, rel_parts in files:
102
+ zf.write(abs_path, arcname="/".join(rel_parts))
103
+ os.replace(tmp_path, zip_path)
104
+ finally:
105
+ if tmp_path.exists():
106
+ tmp_path.unlink()
107
+
108
+
109
+ def build_skill_bundles(
110
+ package_root: Path,
111
+ dest_dir: Path,
112
+ force: bool = False,
113
+ curation: Optional[list[str]] = None,
114
+ ) -> list[Path]:
115
+ """Build per-skill ZIPs under ``dest_dir``.
116
+
117
+ Returns the list of ZIP paths that were (re-)written this call. ZIPs
118
+ skipped because their content digest matched the existing sidecar
119
+ are not in the returned list (but remain on disk).
120
+
121
+ ``curation`` optionally restricts the build to the given skill
122
+ names; ``None`` bundles every skill folder containing ``SKILL.md``.
123
+ """
124
+ skills_root = package_root / ".agent-src" / "skills"
125
+ if not skills_root.is_dir():
126
+ return []
127
+ dest_dir.mkdir(parents=True, exist_ok=True)
128
+ written: list[Path] = []
129
+ for entry in sorted(skills_root.iterdir()):
130
+ if not (entry.is_dir() or entry.is_symlink()):
131
+ continue
132
+ skill_name = entry.name
133
+ if curation is not None and skill_name not in curation:
134
+ continue
135
+ skill_md = entry / "SKILL.md"
136
+ if not skill_md.exists():
137
+ continue
138
+ files = _walk_skill_files(entry)
139
+ if not files:
140
+ continue
141
+ digest = _manifest_digest(files)
142
+ zip_path = dest_dir / f"{skill_name}.zip"
143
+ digest_path = dest_dir / f"{skill_name}.sha256"
144
+ recorded = digest_path.read_text(encoding="utf-8").strip() if digest_path.exists() else ""
145
+ if not force and recorded == digest and zip_path.exists():
146
+ continue
147
+ _atomic_write_zip(zip_path, files)
148
+ digest_path.write_text(digest + "\n", encoding="utf-8")
149
+ written.append(zip_path)
150
+ return written
@@ -1,4 +1,4 @@
1
- """Global-install lockfile at ``~/.config/agent-config/installed.lock``.
1
+ """Global-install lockfile at ``~/.event4u/agent-config/installed.lock``.
2
2
 
3
3
  Phase 1.6 of road-to-global-first-install (ADR-007 D5). Records the
4
4
  package version that performed the most recent user-scope install plus
@@ -10,6 +10,12 @@ flip in ``.agent-settings.yml``.
10
10
  The schema is intentionally minimal YAML so the module can read and
11
11
  write without depending on ``pyyaml``. Atomic writes go through
12
12
  ``tempfile + os.replace`` per ADR-007 risk-mitigation row.
13
+
14
+ Path resolution is delegated to :mod:`scripts._lib.user_global_paths`
15
+ (Phase 1 of road-to-event4u-namespace-and-claude-desktop.md): writes
16
+ land at ``~/.event4u/agent-config/installed.lock``; reads fall back to
17
+ the legacy ``~/.config/agent-config/installed.lock`` if the new path
18
+ is missing, so pre-2.4 installs keep working during the transition.
13
19
  """
14
20
  from __future__ import annotations
15
21
 
@@ -21,10 +27,22 @@ from datetime import datetime, timezone
21
27
  from pathlib import Path
22
28
  from typing import Optional
23
29
 
30
+ from scripts._lib import user_global_paths
31
+
24
32
  LOCKFILE_ENV = "AGENT_CONFIG_INSTALLED_LOCK"
25
- DEFAULT_LOCKFILE = Path.home() / ".config" / "agent-config" / "installed.lock"
26
33
  SCHEMA_VERSION = 1
27
34
 
35
+
36
+ def _default_lockfile() -> Path:
37
+ """Canonical write target for the lockfile (new namespace)."""
38
+ return user_global_paths.write_target("installed.lock")
39
+
40
+
41
+ # Module-level constant retained for back-compat with importers that read
42
+ # ``installed_lock.DEFAULT_LOCKFILE`` directly. Derived from the helper so
43
+ # the path tracks any future override of ``event4u_root()``.
44
+ DEFAULT_LOCKFILE = _default_lockfile()
45
+
28
46
  _VERSION_RE = re.compile(r'^\s*agent_config_version\s*:\s*"?([^"\s]+)"?\s*$')
29
47
  _SCHEMA_RE = re.compile(r"^\s*schema_version\s*:\s*(\d+)\s*$")
30
48
  _INSTALLED_AT_RE = re.compile(r'^\s*installed_at\s*:\s*"?([^"\s]+)"?\s*$')
@@ -32,12 +50,46 @@ _TOOL_RE = re.compile(r"^\s*-\s*([A-Za-z0-9_\-.]+)\s*$")
32
50
 
33
51
 
34
52
  def lockfile_path(env: Optional[dict] = None) -> Path:
35
- """Return the active lockfile path, honoring the env override."""
53
+ """Return the active lockfile path for **reads**, honoring overrides.
54
+
55
+ Resolution order:
56
+
57
+ 1. ``$AGENT_CONFIG_INSTALLED_LOCK`` — explicit full-path override.
58
+ 2. ``~/.event4u/agent-config/installed.lock`` if it exists on disk.
59
+ 3. ``~/.config/agent-config/installed.lock`` (legacy fallback, read-only).
60
+ 4. Canonical write target under the new namespace (Step 2 fallthrough).
61
+
62
+ Readers benefit from (3) so pre-2.4 installs keep working while the
63
+ migration shim has not yet run. Writers must use
64
+ :func:`lockfile_write_path` so a stale legacy file does not anchor
65
+ subsequent writes to the deprecated location.
66
+ """
67
+ env = env if env is not None else os.environ
68
+ override = env.get(LOCKFILE_ENV)
69
+ if override:
70
+ return Path(override).expanduser()
71
+ resolved = user_global_paths.resolve_with_fallback("installed.lock", env=env)
72
+ if resolved is not None:
73
+ return resolved
74
+ return user_global_paths.write_target("installed.lock", env=env)
75
+
76
+
77
+ def lockfile_write_path(env: Optional[dict] = None) -> Path:
78
+ """Return the canonical write target for the lockfile.
79
+
80
+ Unlike :func:`lockfile_path`, this never falls back to the legacy
81
+ ``~/.config/agent-config/`` location. Honors the
82
+ ``$AGENT_CONFIG_INSTALLED_LOCK`` override for tests, otherwise pins
83
+ to ``~/.event4u/agent-config/installed.lock``. Callers in
84
+ ``init``, ``update``, and ``uninstall`` use this so writes always
85
+ land in the new namespace regardless of whether a stale legacy
86
+ lockfile is still present.
87
+ """
36
88
  env = env if env is not None else os.environ
37
89
  override = env.get(LOCKFILE_ENV)
38
90
  if override:
39
91
  return Path(override).expanduser()
40
- return DEFAULT_LOCKFILE
92
+ return user_global_paths.write_target("installed.lock", env=env)
41
93
 
42
94
 
43
95
  def read_lockfile(path: Optional[Path] = None) -> Optional[dict]:
@@ -4,7 +4,7 @@ Phase 3 of road-to-global-first-install (ADR-008). Committed
4
4
  bill-of-materials for AI tooling a project depends on. Sibling to the
5
5
  global lockfile (``installed_lock.py``) but architecturally distinct:
6
6
 
7
- - ``installed_lock.py`` lives in ``~/.config/agent-config/`` and tracks
7
+ - ``installed_lock.py`` lives in ``~/.event4u/agent-config/`` and tracks
8
8
  the user-scope environment (a single ``agent_config_version`` and a
9
9
  flat ``tools[]`` list).
10
10
  - ``installed_tools.py`` lives in ``agents/`` and tracks **per-project**
@@ -10,7 +10,10 @@ Design constraints (see roadmap P2):
10
10
 
11
11
  - Stdlib only (no new deps); the package's Python floor is stdlib-only.
12
12
  - 1 s hard timeout on the registry call; network failure is silent.
13
- - 24 h cadence gated by ``~/.config/agent-config/update-check.json``.
13
+ - 24 h cadence gated by ``~/.event4u/agent-config/update-check.json``
14
+ (legacy ``~/.config/agent-config/update-check.json`` is read once as
15
+ a fallback so the cadence is not reset on the first run after the
16
+ Phase-1 namespace migration).
14
17
  - Suppress in CI, on non-TTY stdout, when ``AGENT_CONFIG_NO_UPDATE_CHECK=1``,
15
18
  or when ``update_check.enabled: false`` in settings.
16
19
  - State file mode is ``0600``.
@@ -30,12 +33,29 @@ from datetime import datetime, timedelta, timezone
30
33
  from pathlib import Path
31
34
  from typing import Optional
32
35
 
36
+ from scripts._lib import user_global_paths
37
+
33
38
  PACKAGE_NAME = "@event4u/agent-config"
34
39
  NPM_REGISTRY_URL = f"https://registry.npmjs.org/{PACKAGE_NAME}/latest"
35
40
  FETCH_TIMEOUT_S = 1.0
36
41
  CHECK_WINDOW = timedelta(hours=24)
37
42
 
38
- DEFAULT_STATE_PATH = Path.home() / ".config" / "agent-config" / "update-check.json"
43
+ STATE_FILENAME = "update-check.json"
44
+
45
+ #: Canonical write target. Reads are routed via
46
+ #: :func:`_resolve_state_path` with a read-fallback to the legacy
47
+ #: ``~/.config/agent-config/update-check.json`` so a fresh install
48
+ #: under the new namespace does not lose the 24 h cadence window
49
+ #: established by a pre-2.4 install.
50
+ DEFAULT_STATE_PATH = user_global_paths.write_target(STATE_FILENAME)
51
+
52
+
53
+ def _resolve_state_path() -> Path:
54
+ """Return the active state path, preferring the new namespace."""
55
+ found = user_global_paths.resolve_with_fallback(STATE_FILENAME)
56
+ if found is not None:
57
+ return found
58
+ return DEFAULT_STATE_PATH
39
59
 
40
60
 
41
61
  def _now_utc() -> datetime:
@@ -159,8 +179,12 @@ def check_for_update(
159
179
  return None
160
180
 
161
181
  now = now or _now_utc()
162
- state_path = state_path or DEFAULT_STATE_PATH
163
- state = _read_state(state_path)
182
+ # When the caller does not pin a state path, route through the
183
+ # fallback resolver so a pre-2.4 install's cadence file is still
184
+ # consulted before we decide to re-check npm.
185
+ read_path = state_path or _resolve_state_path()
186
+ write_path = state_path or DEFAULT_STATE_PATH
187
+ state = _read_state(read_path)
164
188
  if not _should_check(state, now):
165
189
  latest = state.get("last_seen_version")
166
190
  if isinstance(latest, str) and _is_newer(latest, installed_version):
@@ -174,7 +198,7 @@ def check_for_update(
174
198
  "installed_version": installed_version,
175
199
  }
176
200
  try:
177
- _write_state(state_path, payload)
201
+ _write_state(write_path, payload)
178
202
  except OSError:
179
203
  pass
180
204