@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
@@ -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**
@@ -21,17 +21,46 @@ from __future__ import annotations
21
21
 
22
22
  import os
23
23
  import re
24
- import tempfile
25
24
  from datetime import datetime, timezone
26
25
  from pathlib import Path
27
26
  from typing import Any, Optional
28
27
 
28
+ try:
29
+ from scripts._lib.fs_atomic import write_atomic # noqa: PLC0415
30
+ except ImportError: # pragma: no cover — alt sys.path layout (tests)
31
+ from _lib.fs_atomic import write_atomic # type: ignore[no-redef] # noqa: PLC0415
32
+
29
33
  MANIFEST_ENV = "AGENT_CONFIG_INSTALLED_TOOLS"
30
34
  DEFAULT_MANIFEST_RELATIVE = Path("agents") / "installed-tools.lock"
31
- SCHEMA_VERSION = 1
35
+ SCHEMA_VERSION = 2
36
+
37
+ #: Schema versions older writers may have emitted. Reading any of these
38
+ #: succeeds; writing always produces :data:`SCHEMA_VERSION`.
39
+ SCHEMA_VERSIONS_SUPPORTED = (1, 2)
32
40
 
33
41
  _VALID_SCOPES = ("global", "project")
34
42
 
43
+ #: Permitted values for ``files[].kind`` (P1.1, road-to-multi-package-
44
+ #: coexistence). ``bridge`` = team-pointer marker (e.g. ``.cursorrules``);
45
+ #: ``deployed`` = bundle content we wrote (e.g. ``.augment/rules/*.md``);
46
+ #: ``marker`` = one-off sentinel (e.g. ``claude-desktop`` install marker).
47
+ FILE_KINDS = frozenset({"bridge", "deployed", "marker"})
48
+
49
+ #: Stable known deploy roots — directories under which the doctor
50
+ #: command surveys for foreign files. Writers may extend the live
51
+ #: ``deploy_roots`` field per project; this constant is the canonical
52
+ #: default the installer seeds.
53
+ DEFAULT_DEPLOY_ROOTS = (
54
+ ".augment/rules",
55
+ ".augment/skills",
56
+ ".augment/commands",
57
+ ".cursor/rules",
58
+ ".claude/skills",
59
+ ".claude/commands",
60
+ ".clinerules",
61
+ ".windsurf/rules",
62
+ )
63
+
35
64
 
36
65
  def manifest_path(project_root: Path, env: Optional[dict] = None) -> Path:
37
66
  """Return the active manifest path, honoring the env override."""
@@ -56,31 +85,71 @@ def read_manifest(path: Path) -> Optional[dict[str, Any]]:
56
85
 
57
86
  Tolerates partial / malformed files: missing keys yield missing dict
58
87
  entries rather than raising, so a corrupted file does not brick
59
- ``init``.
88
+ ``init``. v1 and v2 wire formats both return the same shape — v2
89
+ optional fields (``deploy_roots``, per-tool ``files`` /
90
+ ``merged_keys``) default to empty lists when absent, so callers can
91
+ iterate without ``.get(..., [])`` boilerplate (P1.2).
60
92
  """
61
93
  try:
62
94
  text = path.read_text(encoding="utf-8")
63
95
  except (FileNotFoundError, OSError):
64
96
  return None
97
+ data: Optional[dict[str, Any]] = None
65
98
  try:
66
99
  import yaml # type: ignore[import-untyped]
67
- data = yaml.safe_load(text) or {}
68
- if isinstance(data, dict):
69
- data.setdefault("tools", [])
70
- return data
100
+ loaded = yaml.safe_load(text) or {}
101
+ if isinstance(loaded, dict):
102
+ data = loaded
71
103
  except ImportError:
72
104
  pass
73
105
  except Exception:
74
106
  # Fall through to the manual parser; corrupt YAML is recoverable
75
107
  # from our strict schema as long as the top-level shape holds.
76
108
  pass
77
- return _parse_manual(text)
109
+ if data is None:
110
+ data = _parse_manual(text)
111
+ return _normalise_v2_shape(data)
112
+
113
+
114
+ def _normalise_v2_shape(data: dict[str, Any]) -> dict[str, Any]:
115
+ """Backfill v2 optional fields so consumers can iterate uniformly.
116
+
117
+ Idempotent: calling on an already-normalised dict is a no-op. Does
118
+ not mutate input lists — replaces missing keys with fresh empties.
119
+ """
120
+ if data.get("tools") is None:
121
+ data["tools"] = []
122
+ if data.get("deploy_roots") is None:
123
+ data["deploy_roots"] = []
124
+ for tool in data["tools"]:
125
+ if not isinstance(tool, dict):
126
+ continue
127
+ if tool.get("files") is None:
128
+ tool["files"] = []
129
+ if tool.get("merged_keys") is None:
130
+ tool["merged_keys"] = []
131
+ return data
78
132
 
79
133
 
80
134
  def _parse_manual(text: str) -> dict[str, Any]:
135
+ """Strict v1 manual parser; v2 nested fields are skipped, not raised.
136
+
137
+ The fallback parser handles the canonical v1 wire format (top-level
138
+ scalars + ``tools`` array with single-level key:value entries). For
139
+ v2 manifests it still extracts the top-level scalars and the
140
+ per-tool scalar fields, but silently drops nested arrays
141
+ (``files``, ``merged_keys``) and top-level ``deploy_roots``. Callers
142
+ that need full v2 fidelity must have ``pyyaml`` available — the
143
+ manual path is a degraded read, not a v2 round-trip.
144
+ """
81
145
  data: dict[str, Any] = {"tools": []}
82
146
  in_tools = False
83
147
  current: Optional[dict[str, Any]] = None
148
+ # When the current tool entry opened a nested array (``files:`` or
149
+ # ``merged_keys:``), we suppress recognition of the deeper ``- key``
150
+ # lines as new tools until the indent climbs back to the per-tool
151
+ # level (4 spaces).
152
+ skip_until_outdent = False
84
153
  for raw in text.splitlines():
85
154
  stripped = raw.strip()
86
155
  if not stripped or stripped.startswith("#"):
@@ -88,25 +157,39 @@ def _parse_manual(text: str) -> dict[str, Any]:
88
157
  if stripped == "tools:":
89
158
  in_tools = True
90
159
  current = None
160
+ skip_until_outdent = False
91
161
  continue
92
162
  if in_tools:
163
+ indent = len(raw) - len(raw.lstrip(" "))
164
+ if skip_until_outdent and indent > 4:
165
+ continue
166
+ skip_until_outdent = False
93
167
  m = _LIST_DASH_RE.match(raw)
94
- if m:
168
+ if m and indent == 2:
95
169
  first = m.group(1)
96
170
  current = {}
97
171
  data["tools"].append(current)
98
- # Could be inline like `- name: foo` — handle that.
99
172
  inline = _TOP_KEY_RE.match(first)
100
173
  if inline:
101
174
  current[inline.group(1)] = inline.group(2)
102
175
  continue
103
176
  mk = _INDENT_KEY_RE.match(raw)
104
- if mk and current is not None:
105
- current[mk.group(1)] = mk.group(2)
177
+ if mk and current is not None and indent == 4:
178
+ key, val = mk.group(1), mk.group(2)
179
+ if key in ("files", "merged_keys") and not val:
180
+ skip_until_outdent = True
181
+ continue
182
+ current[key] = val
106
183
  continue
107
184
  m_top = _TOP_KEY_RE.match(raw)
108
185
  if m_top:
109
186
  key, value = m_top.group(1), m_top.group(2)
187
+ if key == "deploy_roots" and not value:
188
+ # Top-level v2 array — skip until next top-level scalar.
189
+ in_tools = False
190
+ current = None
191
+ skip_until_outdent = True
192
+ continue
110
193
  if key == "schema_version":
111
194
  try:
112
195
  data[key] = int(value)
@@ -116,6 +199,7 @@ def _parse_manual(text: str) -> dict[str, Any]:
116
199
  data[key] = value
117
200
  in_tools = False
118
201
  current = None
202
+ skip_until_outdent = False
119
203
  return data
120
204
 
121
205
 
@@ -127,17 +211,54 @@ def _parse_manual(text: str) -> dict[str, Any]:
127
211
  def _render(
128
212
  version: str,
129
213
  tools: list[dict[str, Any]],
214
+ *,
215
+ deploy_roots: Optional[list[str]] = None,
130
216
  ) -> str:
131
217
  lines = [
132
218
  f"schema_version: {SCHEMA_VERSION}",
133
219
  f'agent_config_version: "{version}"',
134
- "tools:",
135
220
  ]
221
+ if deploy_roots:
222
+ lines.append("deploy_roots:")
223
+ for root in deploy_roots:
224
+ lines.append(f" - {root}")
225
+ lines.append("tools:")
136
226
  for tool in tools:
137
227
  lines.append(f" - name: {tool['name']}")
138
228
  lines.append(f" scope: {tool['scope']}")
139
229
  lines.append(f" bridge_marker: {tool['bridge_marker']}")
140
230
  lines.append(f' installed_at: "{tool["installed_at"]}"')
231
+ status = tool.get("status")
232
+ if status:
233
+ lines.append(f" status: {status}")
234
+ files = tool.get("files") or []
235
+ if files:
236
+ # Sort by path ascending — deterministic output for golden-
237
+ # file tests and stable team diffs (P1.3).
238
+ files = sorted(files, key=lambda f: f["path"])
239
+ lines.append(" files:")
240
+ for entry in files:
241
+ lines.append(f" - path: {entry['path']}")
242
+ lines.append(f" kind: {entry['kind']}")
243
+ sha = entry.get("sha256")
244
+ if sha is None:
245
+ lines.append(" sha256: null")
246
+ else:
247
+ lines.append(f' sha256: "{sha}"')
248
+ merged = tool.get("merged_keys") or []
249
+ if merged:
250
+ # Sort by (file, json_pointer) ascending — deterministic
251
+ # output regardless of insertion order (P1.3).
252
+ merged = sorted(
253
+ merged, key=lambda e: (e["file"], e["json_pointer"]),
254
+ )
255
+ lines.append(" merged_keys:")
256
+ for entry in merged:
257
+ lines.append(f" - file: {entry['file']}")
258
+ lines.append(f" json_pointer: \"{entry['json_pointer']}\"")
259
+ vh = entry.get("value_hash")
260
+ if vh is not None:
261
+ lines.append(f' value_hash: "{vh}"')
141
262
  return "\n".join(lines) + "\n"
142
263
 
143
264
 
@@ -145,24 +266,23 @@ def write_manifest(
145
266
  path: Path,
146
267
  version: str,
147
268
  tools: list[dict[str, Any]],
269
+ *,
270
+ deploy_roots: Optional[list[str]] = None,
148
271
  ) -> Path:
149
- """Atomically write the manifest; return the path written."""
150
- path.parent.mkdir(parents=True, exist_ok=True)
151
- rendered = _render(version, tools)
152
- fd, tmp_name = tempfile.mkstemp(
153
- prefix=".installed-tools.lock.", dir=str(path.parent), text=False
154
- )
155
- try:
156
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
157
- fh.write(rendered)
158
- os.replace(tmp_name, path)
159
- except Exception:
160
- try:
161
- os.unlink(tmp_name)
162
- except OSError:
163
- pass
164
- raise
165
- return path
272
+ """Atomically write the manifest; return the path written.
273
+
274
+ Delegates to :func:`scripts._lib.fs_atomic.write_atomic` so the
275
+ crash-safety guarantees (fsync file, atomic rename, fsync parent
276
+ dir) are shared with every other v2 writer. See P1.0 of
277
+ ``road-to-multi-package-coexistence`` for the rationale.
278
+
279
+ ``deploy_roots`` is the optional top-level v2 field listing
280
+ directories the doctor command surveys for foreign files. When
281
+ omitted, the field is not emitted (callers may rely on
282
+ :data:`DEFAULT_DEPLOY_ROOTS` for the survey scope).
283
+ """
284
+ rendered = _render(version, tools, deploy_roots=deploy_roots)
285
+ return write_atomic(path, rendered)
166
286
 
167
287
 
168
288
  # ---------------------------------------------------------------------------
@@ -191,6 +311,8 @@ def upsert_tool(
191
311
  bridge_marker: str,
192
312
  installed_at: Optional[str] = None,
193
313
  force: bool = False,
314
+ files: Optional[list[dict[str, Any]]] = None,
315
+ merged_keys: Optional[list[dict[str, Any]]] = None,
194
316
  ) -> list[dict[str, Any]]:
195
317
  """Return a new tools list with ``name`` added or refreshed.
196
318
 
@@ -199,10 +321,34 @@ def upsert_tool(
199
321
  * Same name, different scope → raise ``ScopeMismatchError`` unless
200
322
  ``force=True``, in which case the entry is rewritten.
201
323
  * New name → appended in install order (not alphabetised).
324
+
325
+ ``files`` / ``merged_keys`` are the v2 per-tool inventories
326
+ (P1.4). When provided, they replace whatever was previously
327
+ recorded on the entry — the installer is authoritative for the
328
+ set of artefacts it just wrote. When ``None``, existing values
329
+ are preserved on the idempotent path and absent on first-write.
202
330
  """
203
331
  if scope not in _VALID_SCOPES:
204
332
  raise ValueError(f"scope must be one of {_VALID_SCOPES}: {scope!r}")
205
333
  stamp = installed_at or _today()
334
+
335
+ def _build(prior: Optional[dict[str, Any]] = None) -> dict[str, Any]:
336
+ entry: dict[str, Any] = {
337
+ "name": name,
338
+ "scope": scope,
339
+ "bridge_marker": bridge_marker,
340
+ "installed_at": stamp,
341
+ }
342
+ if files is not None:
343
+ entry["files"] = list(files)
344
+ elif prior is not None and prior.get("files"):
345
+ entry["files"] = list(prior["files"])
346
+ if merged_keys is not None:
347
+ entry["merged_keys"] = list(merged_keys)
348
+ elif prior is not None and prior.get("merged_keys"):
349
+ entry["merged_keys"] = list(prior["merged_keys"])
350
+ return entry
351
+
206
352
  result: list[dict[str, Any]] = []
207
353
  found = False
208
354
  for entry in existing:
@@ -210,26 +356,24 @@ def upsert_tool(
210
356
  found = True
211
357
  recorded = str(entry.get("scope", ""))
212
358
  if recorded == scope:
213
- # Idempotent no-op preserve original installed_at.
214
- result.append(entry)
359
+ if files is None and merged_keys is None:
360
+ # Idempotent no-op — preserve original entry.
361
+ result.append(entry)
362
+ else:
363
+ # Refresh inventories, preserve installed_at.
364
+ refreshed = _build(prior=entry)
365
+ refreshed["installed_at"] = entry.get(
366
+ "installed_at", stamp,
367
+ )
368
+ result.append(refreshed)
215
369
  continue
216
370
  if not force:
217
371
  raise ScopeMismatchError(name, recorded, scope)
218
- result.append({
219
- "name": name,
220
- "scope": scope,
221
- "bridge_marker": bridge_marker,
222
- "installed_at": stamp,
223
- })
372
+ result.append(_build(prior=entry))
224
373
  continue
225
374
  result.append(entry)
226
375
  if not found:
227
- result.append({
228
- "name": name,
229
- "scope": scope,
230
- "bridge_marker": bridge_marker,
231
- "installed_at": stamp,
232
- })
376
+ result.append(_build())
233
377
  return result
234
378
 
235
379
 
@@ -0,0 +1,260 @@
1
+ """JSON-pointer helpers for the v2 ``merged_keys[]`` manifest field.
2
+
3
+ P1.5 of road-to-multi-package-coexistence (Anthropic constraint
4
+ 2026-05-12). When a tool merges into a shared JSON file, the manifest
5
+ records which JSON pointers it owns so uninstall can subtract them
6
+ cleanly without touching foreign keys. Two invariants:
7
+
8
+ 1. **No array indices.** Pointers MUST target named object keys only.
9
+ ``/hooks/PostToolUse`` is valid; ``/hooks/PostToolUse/0`` is not.
10
+ Array indices shift when another tool inserts or removes entries
11
+ at the same array, so an index-based pointer corrupts other
12
+ packages' ownership records on neighbour-tool uninstall.
13
+ 2. **Arrays carry a ``value_hash`` discriminator.** A pointer that
14
+ targets a parent whose value is a list records the SHA-256 of the
15
+ JSON-serialised list contents the install wrote, so uninstall can
16
+ identify the owned elements by content rather than position.
17
+
18
+ This module is dependency-free (stdlib only) so it can be imported in
19
+ both the installer (``scripts/install.py``) and the manifest layer
20
+ (``scripts/_lib/installed_tools.py``).
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ from typing import Any, Optional
27
+
28
+
29
+ class ArrayIndexPointerError(ValueError):
30
+ """Raised when a JSON pointer segment is an array index."""
31
+
32
+ def __init__(self, pointer: str, segment: str):
33
+ super().__init__(
34
+ f"json_pointer {pointer!r} targets array index {segment!r}; "
35
+ "pointers MUST target named object keys only "
36
+ "(see road-to-multi-package-coexistence.md § P1.5)"
37
+ )
38
+ self.pointer = pointer
39
+ self.segment = segment
40
+
41
+
42
+ def _escape_segment(key: str) -> str:
43
+ """Escape a JSON pointer segment per RFC 6901 § 4."""
44
+ return key.replace("~", "~0").replace("/", "~1")
45
+
46
+
47
+ def validate_pointer(pointer: str) -> None:
48
+ """Raise :class:`ArrayIndexPointerError` if any segment is an integer.
49
+
50
+ The empty pointer (``""``) is valid (targets the document root).
51
+ Otherwise the pointer must start with ``/`` and split into
52
+ segments; each segment that parses cleanly as a non-negative
53
+ integer is rejected (RFC 6901 array-index syntax).
54
+ """
55
+ if pointer == "":
56
+ return
57
+ if not pointer.startswith("/"):
58
+ raise ValueError(
59
+ f"json_pointer {pointer!r} must start with '/' (RFC 6901)"
60
+ )
61
+ # Skip the leading empty segment from the leading slash.
62
+ segments = pointer.split("/")[1:]
63
+ for seg in segments:
64
+ # RFC 6901 § 4 — array index = unsigned integer, no leading zero
65
+ # except for "0" itself.
66
+ if seg.isdigit() and (seg == "0" or not seg.startswith("0")):
67
+ raise ArrayIndexPointerError(pointer, seg)
68
+
69
+
70
+ def value_hash(value: Any) -> str:
71
+ """Return a stable SHA-256 hex digest of ``value`` (JSON-serialised).
72
+
73
+ Uses canonical JSON (sorted keys, no whitespace) so the hash is
74
+ insertion-order independent. Used to discriminate tool-owned
75
+ entries in a shared array on uninstall.
76
+ """
77
+ payload = json.dumps(value, sort_keys=True, separators=(",", ":"))
78
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
79
+
80
+
81
+ def collect_pointers(
82
+ overlay: dict,
83
+ *,
84
+ prefix: str = "",
85
+ include_arrays: bool = True,
86
+ ) -> list[dict[str, Any]]:
87
+ """Walk an overlay dict and return one entry per object-key pointer.
88
+
89
+ Each entry: ``{"json_pointer": str, "value_hash": Optional[str]}``.
90
+ ``value_hash`` is set when the targeted value is a list (arrays
91
+ need content-hash discrimination on uninstall); for nested dicts
92
+ we recurse and emit a pointer for each inner key. Scalars get a
93
+ pointer with ``value_hash=None`` (the key/value pair fully
94
+ identifies the merge).
95
+
96
+ The collector NEVER emits array-index pointers — list contents
97
+ are owned wholesale at the parent key.
98
+ """
99
+ entries: list[dict[str, Any]] = []
100
+ for key, value in overlay.items():
101
+ pointer = f"{prefix}/{_escape_segment(str(key))}"
102
+ if isinstance(value, dict):
103
+ # Recurse so the manifest captures the leaf object keys,
104
+ # not just the root container. Empty dicts get a single
105
+ # entry at the key so an uninstall can still remove them.
106
+ if not value:
107
+ entries.append({"json_pointer": pointer, "value_hash": None})
108
+ else:
109
+ entries.extend(
110
+ collect_pointers(
111
+ value, prefix=pointer, include_arrays=include_arrays,
112
+ )
113
+ )
114
+ elif isinstance(value, list):
115
+ entries.append(
116
+ {
117
+ "json_pointer": pointer,
118
+ "value_hash": value_hash(value) if include_arrays else None,
119
+ }
120
+ )
121
+ else:
122
+ entries.append({"json_pointer": pointer, "value_hash": None})
123
+ # Validate every emitted pointer once at the end — cheap and
124
+ # guarantees the invariant even if a future caller hand-crafts
125
+ # entries.
126
+ for entry in entries:
127
+ validate_pointer(entry["json_pointer"])
128
+ return entries
129
+
130
+
131
+ def build_merge_entries(
132
+ file_label: str,
133
+ overlay: dict,
134
+ ) -> list[dict[str, Any]]:
135
+ """Return v2 ``merged_keys[]`` entries for a single JSON merge.
136
+
137
+ ``file_label`` is the manifest-relative file path the merge
138
+ touched (e.g. ``.cursor/hooks.json``). The overlay is the dict the
139
+ installer wrote into the file; only its top-level object keys
140
+ become pointers (recursing through nested objects, halting at
141
+ lists / scalars).
142
+ """
143
+ pointers = collect_pointers(overlay)
144
+ return [
145
+ {
146
+ "file": file_label,
147
+ "json_pointer": entry["json_pointer"],
148
+ "value_hash": entry["value_hash"],
149
+ }
150
+ for entry in pointers
151
+ ]
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Subtraction (P2.2 — uninstall round-trip)
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ def _split_segments(pointer: str) -> list[str]:
160
+ """Split a non-empty pointer into unescaped segments."""
161
+ if pointer == "":
162
+ return []
163
+ # RFC 6901: leading '/' separates segments; unescape ~1 → '/' and ~0 → '~'.
164
+ parts = pointer.split("/")[1:]
165
+ return [p.replace("~1", "/").replace("~0", "~") for p in parts]
166
+
167
+
168
+ def _navigate(doc: Any, segments: list[str]) -> tuple[Any, str] | None:
169
+ """Walk ``doc`` down ``segments`` and return ``(parent_dict, leaf_key)``.
170
+
171
+ Returns ``None`` when any intermediate segment is missing or not a
172
+ dict (we never descend into lists by index, see :func:`validate_pointer`).
173
+ """
174
+ if not segments:
175
+ return None
176
+ cursor = doc
177
+ for seg in segments[:-1]:
178
+ if not isinstance(cursor, dict) or seg not in cursor:
179
+ return None
180
+ cursor = cursor[seg]
181
+ if not isinstance(cursor, dict):
182
+ return None
183
+ leaf = segments[-1]
184
+ if leaf not in cursor:
185
+ return None
186
+ return cursor, leaf
187
+
188
+
189
+ def subtract_pointers(
190
+ doc: dict,
191
+ entries: list[dict[str, Any]],
192
+ ) -> tuple[dict, list[dict[str, Any]]]:
193
+ """Remove the pointers in ``entries`` from ``doc``; trim empty ancestors.
194
+
195
+ ``entries`` is a list of ``{"json_pointer": str, "value_hash":
196
+ Optional[str]}`` records (the per-file slice of a tool's
197
+ ``merged_keys[]``). For each entry:
198
+
199
+ * ``value_hash is None`` → delete the key at the pointer.
200
+ * ``value_hash is set`` → the target is a list owned wholesale by
201
+ the tool. Delete only when the current value's hash still
202
+ matches; otherwise treat as **drift** (a neighbour package or
203
+ the user edited the array) and skip, surfacing a warning.
204
+
205
+ After every leaf removal we walk up the ancestor chain and drop
206
+ any empty dict the removal left behind — but only empty ones. A
207
+ neighbour tool's remaining keys keep the container alive, so its
208
+ contributions are never touched.
209
+
210
+ Returns ``(updated_doc, warnings)`` where ``warnings`` is a list of
211
+ ``{"pointer": str, "reason": "missing" | "drift", "expected_hash":
212
+ Optional[str], "actual_hash": Optional[str]}`` entries describing
213
+ pointers that could not be subtracted cleanly.
214
+ """
215
+ warnings: list[dict[str, Any]] = []
216
+ # Sort longest-first so leaves are removed before their ancestors —
217
+ # otherwise ancestor cleanup races leaf removal in deep trees.
218
+ ordered = sorted(
219
+ entries,
220
+ key=lambda e: len(_split_segments(e["json_pointer"])),
221
+ reverse=True,
222
+ )
223
+ for entry in ordered:
224
+ pointer = entry["json_pointer"]
225
+ expected = entry.get("value_hash")
226
+ segments = _split_segments(pointer)
227
+ nav = _navigate(doc, segments)
228
+ if nav is None:
229
+ warnings.append({
230
+ "pointer": pointer,
231
+ "reason": "missing",
232
+ "expected_hash": expected,
233
+ "actual_hash": None,
234
+ })
235
+ continue
236
+ parent, leaf = nav
237
+ if expected is not None:
238
+ actual = value_hash(parent[leaf])
239
+ if actual != expected:
240
+ warnings.append({
241
+ "pointer": pointer,
242
+ "reason": "drift",
243
+ "expected_hash": expected,
244
+ "actual_hash": actual,
245
+ })
246
+ continue
247
+ del parent[leaf]
248
+ # Trim empty-ancestor chain — never remove a container that
249
+ # still holds foreign keys.
250
+ for depth in range(len(segments) - 1, 0, -1):
251
+ ancestor_segments = segments[:depth]
252
+ anc_nav = _navigate(doc, ancestor_segments)
253
+ if anc_nav is None:
254
+ break
255
+ anc_parent, anc_leaf = anc_nav
256
+ if isinstance(anc_parent[anc_leaf], dict) and not anc_parent[anc_leaf]:
257
+ del anc_parent[anc_leaf]
258
+ continue
259
+ break
260
+ return doc, warnings
@@ -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