@event4u/agent-config 2.2.2 → 2.4.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.
Files changed (68) hide show
  1. package/.agent-src/commands/onboard.md +14 -9
  2. package/.agent-src/rules/external-reference-deep-dive.md +69 -0
  3. package/.agent-src/skills/ai-council/SKILL.md +5 -3
  4. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -1
  5. package/.agent-src/templates/agents/agent-project-settings.example.yml +4 -3
  6. package/.agent-src/templates/copilot-instructions.md +7 -0
  7. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +29 -7
  8. package/.agent-src/templates/scripts/work_engine/_lib/user_global_paths.py +249 -0
  9. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +8 -5
  10. package/.claude-plugin/marketplace.json +27 -1
  11. package/CHANGELOG.md +79 -0
  12. package/README.md +1 -8
  13. package/config/agent-settings.template.yml +5 -3
  14. package/docs/architecture.md +1 -1
  15. package/docs/catalog.md +5 -3
  16. package/docs/contracts/installed-tools-lockfile.md +142 -0
  17. package/docs/customization.md +23 -17
  18. package/docs/decisions/ADR-007-agent-discovery-scopes.md +6 -0
  19. package/docs/decisions/ADR-009-event4u-namespace.md +188 -0
  20. package/docs/decisions/INDEX.md +1 -0
  21. package/docs/development.md +37 -0
  22. package/docs/getting-started.md +1 -1
  23. package/docs/guidelines/agent-infra/installed-tools-manifest.md +1 -1
  24. package/docs/guidelines/agent-infra/layered-settings.md +6 -4
  25. package/docs/installation.md +17 -2
  26. package/docs/migration/v1-to-v2.md +45 -0
  27. package/docs/setup/per-ide/antigravity.md +63 -0
  28. package/docs/setup/per-ide/augment.md +77 -0
  29. package/docs/setup/per-ide/claude-desktop.md +107 -65
  30. package/docs/setup/per-ide/codebuddy.md +63 -0
  31. package/docs/setup/per-ide/continue.md +68 -0
  32. package/docs/setup/per-ide/droid.md +65 -0
  33. package/docs/setup/per-ide/jetbrains.md +76 -0
  34. package/docs/setup/per-ide/kilocode.md +66 -0
  35. package/docs/setup/per-ide/kiro.md +72 -0
  36. package/docs/setup/per-ide/opencode.md +62 -0
  37. package/docs/setup/per-ide/qoder.md +63 -0
  38. package/docs/setup/per-ide/roocode.md +68 -0
  39. package/docs/setup/per-ide/trae.md +63 -0
  40. package/docs/setup/per-ide/warp.md +63 -0
  41. package/docs/setup/per-ide/zed.md +73 -0
  42. package/package.json +1 -1
  43. package/scripts/_cli/cmd_doctor.py +351 -0
  44. package/scripts/_cli/cmd_prune.py +317 -0
  45. package/scripts/_cli/cmd_uninstall.py +465 -0
  46. package/scripts/_cli/cmd_update.py +30 -4
  47. package/scripts/_cli/cmd_versions.py +147 -0
  48. package/scripts/_lib/agent_settings.py +29 -7
  49. package/scripts/_lib/agents_overlay.py +15 -4
  50. package/scripts/_lib/claude_desktop_bundler.py +150 -0
  51. package/scripts/_lib/fs_atomic.py +116 -0
  52. package/scripts/_lib/installed_lock.py +37 -4
  53. package/scripts/_lib/installed_tools.py +189 -45
  54. package/scripts/_lib/json_pointers.py +260 -0
  55. package/scripts/_lib/update_check.py +29 -5
  56. package/scripts/_lib/user_global_paths.py +249 -0
  57. package/scripts/agent-config +69 -0
  58. package/scripts/ai_council/__init__.py +4 -3
  59. package/scripts/ai_council/budget_guard.py +34 -4
  60. package/scripts/ai_council/bundler.py +2 -0
  61. package/scripts/ai_council/clients.py +28 -7
  62. package/scripts/compress.py +78 -15
  63. package/scripts/install +8 -0
  64. package/scripts/install-hooks.sh +54 -1
  65. package/scripts/install.py +1149 -53
  66. package/scripts/install_anthropic_key.sh +5 -3
  67. package/scripts/install_openai_key.sh +5 -3
  68. package/scripts/skill_trigger_eval.py +13 -2
@@ -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>/.claude/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 ``.claude/skills/`` contains the
55
+ 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 / ".claude" / "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
@@ -0,0 +1,116 @@
1
+ """Atomic file-write primitive shared by lockfile-schema-v2 writers.
2
+
3
+ P1.0 of road-to-multi-package-coexistence. Single source of truth for
4
+ crash-safe writes used by ``installed_tools.write_manifest``,
5
+ P1.5 merge tracking, P2.2 uninstall, and P3.x conflict-resolution
6
+ paths. Centralising the mechanism prevents per-phase implementation
7
+ drift (Council amendment, Anthropic 2026-05-12).
8
+
9
+ Guarantees, in order:
10
+
11
+ 1. Write to ``<path>.tmp.<pid>.<rand>`` in the same directory as the
12
+ target. Same-directory keeps the final ``os.replace`` atomic on
13
+ every POSIX filesystem we support; cross-fs renames are not atomic.
14
+ 2. ``fsync(tmp_fd)`` flushes the file's data + metadata to disk before
15
+ we let the temp file become the visible target.
16
+ 3. ``os.replace(tmp, path)`` is the atomic rename. Either the old or
17
+ the new content is visible to readers; never a half-written mix.
18
+ 4. ``fsync(parent_dir_fd)`` durably commits the directory entry so a
19
+ crash immediately after the rename does not resurrect the old file
20
+ on next boot. Skipped on platforms where directory fsync is
21
+ unsupported (Windows) — the rename is still atomic from the
22
+ filesystem's perspective, only durability across power loss is
23
+ weaker there.
24
+
25
+ The temp file is always cleaned up on failure, so a raise mid-write
26
+ never leaves orphaned ``.tmp.*`` siblings behind.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import tempfile
32
+ from pathlib import Path
33
+ from typing import Union
34
+
35
+ __all__ = ["write_atomic"]
36
+
37
+
38
+ def write_atomic(
39
+ path: Union[str, Path],
40
+ data: Union[str, bytes],
41
+ *,
42
+ encoding: str = "utf-8",
43
+ ) -> Path:
44
+ """Atomically write ``data`` to ``path``; return the resolved path.
45
+
46
+ ``data`` may be ``str`` (encoded via ``encoding``) or ``bytes``
47
+ (written verbatim, ``encoding`` ignored). The parent directory is
48
+ created if missing — callers don't have to ``mkdir`` beforehand.
49
+
50
+ On failure (any exception raised by the OS during write / fsync /
51
+ rename), the temporary file is unlinked and the original target —
52
+ if any — is untouched. The exception propagates so callers can
53
+ distinguish disk-full from permission errors etc.
54
+ """
55
+ target = Path(path)
56
+ target.parent.mkdir(parents=True, exist_ok=True)
57
+
58
+ if isinstance(data, str):
59
+ payload = data.encode(encoding)
60
+ elif isinstance(data, (bytes, bytearray)):
61
+ payload = bytes(data)
62
+ else:
63
+ raise TypeError(
64
+ f"write_atomic: data must be str or bytes, got {type(data).__name__}"
65
+ )
66
+
67
+ fd, tmp_name = tempfile.mkstemp(
68
+ prefix=f".{target.name}.tmp.",
69
+ dir=str(target.parent),
70
+ )
71
+ tmp_path = Path(tmp_name)
72
+ try:
73
+ with os.fdopen(fd, "wb") as fh:
74
+ fh.write(payload)
75
+ fh.flush()
76
+ try:
77
+ os.fsync(fh.fileno())
78
+ except OSError:
79
+ # File-level fsync unsupported (e.g. some tmpfs).
80
+ # Continue — os.replace is still atomic.
81
+ pass
82
+ os.replace(tmp_path, target)
83
+ except Exception:
84
+ try:
85
+ tmp_path.unlink()
86
+ except OSError:
87
+ pass
88
+ raise
89
+
90
+ _fsync_dir(target.parent)
91
+ return target
92
+
93
+
94
+ def _fsync_dir(directory: Path) -> None:
95
+ """Best-effort directory fsync; silent no-op on unsupported platforms.
96
+
97
+ Directory fsync is required on POSIX for the rename's durability
98
+ across power loss. Windows does not expose ``open(dir)`` /
99
+ ``fsync(dir_fd)`` semantics — the kernel commits the directory
100
+ entry implicitly. We swallow the OSError there rather than fail
101
+ the write.
102
+ """
103
+ try:
104
+ dir_fd = os.open(str(directory), os.O_RDONLY)
105
+ except OSError:
106
+ return
107
+ try:
108
+ try:
109
+ os.fsync(dir_fd)
110
+ except OSError:
111
+ # Some filesystems / mounts reject directory fsync.
112
+ # The rename is still atomic — durability is weaker but
113
+ # the write is not corrupted.
114
+ pass
115
+ finally:
116
+ os.close(dir_fd)
@@ -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,27 @@ _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, honoring the env override.
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
+ Writers always end up at (4) when no lockfile exists yet; readers
63
+ benefit from (3) so pre-2.4 installs keep working while the
64
+ migration shim has not yet run.
65
+ """
36
66
  env = env if env is not None else os.environ
37
67
  override = env.get(LOCKFILE_ENV)
38
68
  if override:
39
69
  return Path(override).expanduser()
40
- return DEFAULT_LOCKFILE
70
+ resolved = user_global_paths.resolve_with_fallback("installed.lock", env=env)
71
+ if resolved is not None:
72
+ return resolved
73
+ return user_global_paths.write_target("installed.lock", env=env)
41
74
 
42
75
 
43
76
  def read_lockfile(path: Optional[Path] = None) -> Optional[dict]: