@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.
- package/.agent-src/commands/onboard.md +14 -9
- package/.agent-src/rules/external-reference-deep-dive.md +69 -0
- package/.agent-src/skills/ai-council/SKILL.md +5 -3
- package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -1
- package/.agent-src/templates/agents/agent-project-settings.example.yml +4 -3
- package/.agent-src/templates/copilot-instructions.md +7 -0
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +29 -7
- package/.agent-src/templates/scripts/work_engine/_lib/user_global_paths.py +249 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +8 -5
- package/.claude-plugin/marketplace.json +27 -1
- package/CHANGELOG.md +79 -0
- package/README.md +1 -8
- package/config/agent-settings.template.yml +5 -3
- package/docs/architecture.md +1 -1
- package/docs/catalog.md +5 -3
- package/docs/contracts/installed-tools-lockfile.md +142 -0
- package/docs/customization.md +23 -17
- package/docs/decisions/ADR-007-agent-discovery-scopes.md +6 -0
- package/docs/decisions/ADR-009-event4u-namespace.md +188 -0
- package/docs/decisions/INDEX.md +1 -0
- package/docs/development.md +37 -0
- package/docs/getting-started.md +1 -1
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +1 -1
- package/docs/guidelines/agent-infra/layered-settings.md +6 -4
- package/docs/installation.md +17 -2
- package/docs/migration/v1-to-v2.md +45 -0
- package/docs/setup/per-ide/antigravity.md +63 -0
- package/docs/setup/per-ide/augment.md +77 -0
- package/docs/setup/per-ide/claude-desktop.md +107 -65
- package/docs/setup/per-ide/codebuddy.md +63 -0
- package/docs/setup/per-ide/continue.md +68 -0
- package/docs/setup/per-ide/droid.md +65 -0
- package/docs/setup/per-ide/jetbrains.md +76 -0
- package/docs/setup/per-ide/kilocode.md +66 -0
- package/docs/setup/per-ide/kiro.md +72 -0
- package/docs/setup/per-ide/opencode.md +62 -0
- package/docs/setup/per-ide/qoder.md +63 -0
- package/docs/setup/per-ide/roocode.md +68 -0
- package/docs/setup/per-ide/trae.md +63 -0
- package/docs/setup/per-ide/warp.md +63 -0
- package/docs/setup/per-ide/zed.md +73 -0
- package/package.json +1 -1
- package/scripts/_cli/cmd_doctor.py +351 -0
- package/scripts/_cli/cmd_prune.py +317 -0
- package/scripts/_cli/cmd_uninstall.py +465 -0
- package/scripts/_cli/cmd_update.py +30 -4
- package/scripts/_cli/cmd_versions.py +147 -0
- package/scripts/_lib/agent_settings.py +29 -7
- package/scripts/_lib/agents_overlay.py +15 -4
- package/scripts/_lib/claude_desktop_bundler.py +150 -0
- package/scripts/_lib/fs_atomic.py +116 -0
- package/scripts/_lib/installed_lock.py +37 -4
- package/scripts/_lib/installed_tools.py +189 -45
- package/scripts/_lib/json_pointers.py +260 -0
- package/scripts/_lib/update_check.py +29 -5
- package/scripts/_lib/user_global_paths.py +249 -0
- package/scripts/agent-config +69 -0
- package/scripts/ai_council/__init__.py +4 -3
- package/scripts/ai_council/budget_guard.py +34 -4
- package/scripts/ai_council/bundler.py +2 -0
- package/scripts/ai_council/clients.py +28 -7
- package/scripts/compress.py +78 -15
- package/scripts/install +8 -0
- package/scripts/install-hooks.sh +54 -1
- package/scripts/install.py +1149 -53
- package/scripts/install_anthropic_key.sh +5 -3
- package/scripts/install_openai_key.sh +5 -3
- 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 ``~/.
|
|
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 =
|
|
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
|
-
|
|
68
|
-
if isinstance(
|
|
69
|
-
data
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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 ``~/.
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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(
|
|
201
|
+
_write_state(write_path, payload)
|
|
178
202
|
except OSError:
|
|
179
203
|
pass
|
|
180
204
|
|